orca/src/main/ipc/pty.test.ts

14621 lines
505 KiB
TypeScript

/* eslint-disable max-lines -- Why: stateful registration helper + shared mocked IPC/node-pty harness keep spawn-env assertions in one focused file. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { userInfo } from 'node:os'
import { delimiter, join, posix } from 'node:path'
import {
TERMINAL_INPUT_CHUNK_MAX_BYTES,
TERMINAL_INPUT_MAX_BYTES
} from '../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { TuiAgent } from '../../shared/types'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
const expectedOmpStatusExtension = posix.join(
'/tmp/default-omp-agent',
'extensions',
'orca-agent-status.ts'
)
function expectedAttributionShimDir(): string {
return join(
'/tmp/orca-user-data',
'orca-terminal-attribution',
process.platform === 'win32' ? 'win32' : 'posix'
)
}
const {
handleMock,
onMock,
removeHandlerMock,
removeAllListenersMock,
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
readFileSyncMock,
writeFileSyncMock,
chmodSyncMock,
getPathMock,
loginPreflightExecFileMock,
spawnMock,
openCodeBuildPtyEnvMock,
openCodeClearPtyMock,
mimoCodeBuildPtyEnvMock,
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
piBuildPtyEnvMock,
piClearPtyMock,
isPwshAvailableMock,
trackMock,
classifyErrorMock,
registerPtyMock,
unregisterPtyMock,
setMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtysForPaneKeyMock,
clearPaneKeyAliasesForPtyMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
removeHandlerMock: vi.fn(),
removeAllListenersMock: vi.fn(),
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
chmodSyncMock: vi.fn(),
getPathMock: vi.fn(),
loginPreflightExecFileMock: vi.fn(),
spawnMock: vi.fn(),
openCodeBuildPtyEnvMock: vi.fn(),
mimoCodeBuildPtyEnvMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
openCodeClearPtyMock: vi.fn(),
buildAgentHookEnvMock: vi.fn(),
clearAgentHookPaneStateMock: vi.fn(),
registerPaneKeyAliasMock: vi.fn(),
piBuildPtyEnvMock: vi.fn(),
piClearPtyMock: vi.fn(),
trackMock: vi.fn(),
classifyErrorMock: vi.fn(),
registerPtyMock: vi.fn(),
unregisterPtyMock: vi.fn(),
setMigrationUnsupportedPtyMock: vi.fn(),
clearMigrationUnsupportedPtyMock: vi.fn(),
clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(),
clearPaneKeyAliasesForPtyMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
isPackaged: true,
getPath: getPathMock,
getVersion: () => '0.0.0-test'
},
powerMonitor: {
on: vi.fn()
},
nativeTheme: {
shouldUseDarkColors: true
},
ipcMain: {
handle: handleMock,
on: onMock,
removeHandler: removeHandlerMock,
removeAllListeners: removeAllListenersMock
}
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
readFileSync: readFileSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: chmodSyncMock,
constants: {
X_OK: 1
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
// Why: this suite forces darwin on non-macOS hosts; isolate the PAM probe while preserving other child_process APIs.
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
execFile: loginPreflightExecFileMock
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
clearPty: openCodeClearPtyMock
}
}))
vi.mock('../mimo/hook-service', () => ({
mimoCodeHookService: {
buildPtyEnv: mimoCodeBuildPtyEnvMock
}
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
buildPtyEnv: buildAgentHookEnvMock,
clearPaneState: clearAgentHookPaneStateMock,
registerPaneKeyAlias: registerPaneKeyAliasMock,
clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
}
}))
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: {
buildPtyEnv: piBuildPtyEnvMock,
clearPty: piClearPtyMock
}
}))
vi.mock('../pwsh', () => ({
isPwshAvailable: isPwshAvailableMock
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/classify-error', () => ({
classifyError: classifyErrorMock
}))
// Why: the real ensure writes to process.resourcesPath (absent under vitest); env assembly only needs the returned dir path.
vi.mock('../cli/linux-terminal-orca-cli-shim', () => ({
ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) =>
join(options.userDataPath, 'linux-orca-cli-shim')
}))
vi.mock('../memory/pty-registry', () => ({
registerPty: registerPtyMock,
unregisterPty: unregisterPtyMock
}))
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
setMigrationUnsupportedPty: setMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPty: clearMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtysForPaneKey: clearMigrationUnsupportedPtysForPaneKeyMock
}))
import { LocalPtyProvider } from '../providers/local-pty-provider'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import {
registerPtyHandlers,
registerSshPtyProvider,
clearPtyOwnershipForConnection,
clearProviderPtyState,
deletePtyOwnership,
getPtyRendererDeliveryDebugSnapshot,
getPtyIdsForConnection,
resetPtyRendererDeliveryDebug,
getPtyIdForPaneKey,
hasPendingRendererSerializerForPaneKey,
setPtyOwnership,
setLocalPtyProvider,
rebindLocalProviderListeners,
unregisterSshPtyProvider,
getLocalPtyProvider,
isCurrentPtyExit,
restorePtyIncarnation
} from './pty'
import { _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider'
import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell'
import {
_resetHiddenRendererPtyDeliveryGateForTest,
isHiddenRendererPty
} from './pty-hidden-delivery-gate'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
import * as livePtyGate from '../claude-accounts/live-pty-gate'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_SESSION_EXPIRED_ERROR
} from '../providers/ssh-pty-errors'
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161).
const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const RESOLVED_PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
// Why: default spawn cwd in the Windows UTF-8 suite is USERPROFILE; derive shell
// args from the production resolver so expectations stay in lockstep when the
// PowerShell bootstrap grows (e.g. cwd restore after profiles load).
const DEFAULT_WINDOWS_PTY_CWD = 'C:\\Users\\test'
function powerShellOsc133ArgsForCwd(cwd: string = DEFAULT_WINDOWS_PTY_CWD): string[] {
return resolveWindowsShellLaunchArgs(RESOLVED_WINDOWS_POWERSHELL, cwd, cwd).shellArgs
}
const POWERSHELL_OSC133_ARGS = powerShellOsc133ArgsForCwd()
const TEST_CODEX_HOME =
process.platform === 'win32'
? 'C:\\Users\\test\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
: '/tmp/orca-codex-home'
function makeDisposable() {
return { dispose: vi.fn() }
}
function makeDeferred() {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
describe('registerPtyHandlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mainWindow = {
isDestroyed: () => false,
isFocused: () => true,
isVisible: () => true,
isMinimized: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn(),
// Why: the did-start-loading reset handler filters to main-frame loads; default true so lifecycle-reset tests reset (a subframe case overrides false).
isLoadingMainFrame: vi.fn(() => true)
}
}
const mainWindowIpcEvent = { sender: mainWindow.webContents }
const foreignWindowIpcEvent = {
sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
}
const savedOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR
const savedOrcaOpenCodeSourceConfigDir = process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR
const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR
const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR
const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME
const savedOrcaOmpAgentDir = process.env.ORCA_OMP_CODING_AGENT_DIR
const savedOrcaOmpSourceAgentDir = process.env.ORCA_OMP_SOURCE_AGENT_DIR
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
const savedOrcaUserDataPath = process.env.ORCA_USER_DATA_PATH
beforeEach(() => {
// Why: most PTY spawn tests assert POSIX shell behavior; Windows cases opt into win32 explicitly below.
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
// Why: forced darwin makes the TCC login(1) wrapper rewrite every asserted argv; its own test below re-enables it.
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_CONFIG_DIR
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
delete process.env.PI_CODING_AGENT_DIR
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
delete process.env.ORCA_PI_CODING_AGENT_DIR
delete process.env.ORCA_CODEX_HOME
delete process.env.ORCA_OMP_SOURCE_AGENT_DIR
delete process.env.ORCA_OMP_CODING_AGENT_DIR
delete process.env.ORCA_OMP_STATUS_EXTENSION
handlers.clear()
handleMock.mockReset()
onMock.mockReset()
removeHandlerMock.mockReset()
removeAllListenersMock.mockReset()
existsSyncMock.mockReset()
statSyncMock.mockReset()
accessSyncMock.mockReset()
mkdirSyncMock.mockReset()
readFileSyncMock.mockReset()
writeFileSyncMock.mockReset()
chmodSyncMock.mockReset()
getPathMock.mockReset()
loginPreflightExecFileMock.mockReset()
spawnMock.mockReset()
openCodeBuildPtyEnvMock.mockReset()
mimoCodeBuildPtyEnvMock.mockReset()
openCodeClearPtyMock.mockReset()
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
piBuildPtyEnvMock.mockReset()
piClearPtyMock.mockReset()
isPwshAvailableMock.mockReset()
trackMock.mockReset()
classifyErrorMock.mockReset()
registerPtyMock.mockReset()
unregisterPtyMock.mockReset()
setMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtysForPaneKeyMock.mockReset()
clearPaneKeyAliasesForPtyMock.mockReset()
mainWindow.webContents.on.mockReset()
mainWindow.webContents.send.mockReset()
mainWindow.webContents.removeListener.mockReset()
// Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases.
_resetHiddenRendererPtyDeliveryGateForTest()
// Why: mirror real Electron — ipcMain.handle throws on a duplicate channel, catching re-registration that forgot removeHandler.
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
if (handlers.has(channel)) {
throw new Error(`Attempted to register a second handler for '${channel}'`)
}
handlers.set(channel, handler)
})
removeHandlerMock.mockImplementation((channel: string) => {
handlers.delete(channel)
})
// Why: production gates PTY sends on pty:rendererDispatcherReady; model a live page by firing the handshake as soon as it registers.
onMock.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => {
if (channel === 'pty:rendererDispatcherReady') {
listener(mainWindowIpcEvent)
// Drain the handshake's empty flush so it can't later perturb send-timing assertions.
if (vi.isFakeTimers()) {
vi.advanceTimersByTime(0)
}
}
})
getPathMock.mockReturnValue('/tmp/orca-user-data')
// Why: wrapper roots resolve from ORCA_USER_DATA_PATH; mirror the mocked userData so ZDOTDIR/wrapper assertions match.
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-user-data'
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
readFileSyncMock.mockReturnValue('')
openCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({
ORCA_OPENCODE_HOOK_PORT: '4567',
ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token',
ORCA_OPENCODE_PTY_ID: 'test-pty',
OPENCODE_CONFIG_DIR: existingConfigDir
? '/tmp/orca-opencode-overlay'
: '/tmp/orca-opencode-config'
}))
mimoCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingHome?: string) => ({
MIMOCODE_HOME: existingHome ? '/tmp/orca-mimocode-overlay' : '/tmp/orca-mimocode-shared'
}))
buildAgentHookEnvMock.mockReturnValue({
ORCA_AGENT_HOOK_PORT: '5678',
ORCA_AGENT_HOOK_TOKEN: 'agent-token'
})
piBuildPtyEnvMock.mockImplementation(
(_ptyId: string, existingAgentDir?: string, kind?: string) =>
kind === 'omp'
? {
ORCA_OMP_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-omp-agent',
ORCA_OMP_STATUS_EXTENSION: `${existingAgentDir ?? '/tmp/default-omp-agent'}/extensions/orca-agent-status.ts`
}
: {
ORCA_PI_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-pi-agent'
}
)
isPwshAvailableMock.mockReturnValue(false)
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
process: 'zsh',
pid: 12345
})
})
afterEach(() => {
_resetLocalPtyProviderStateForTest()
_resetWslCachesForTests()
vi.useRealTimers()
// Why: sshProviders is module-level state; a leftover id leaks into later tests (pty:listSessions sweeps every provider).
for (const leakedConnectionId of [
'ssh-1',
'ssh-a',
'ssh-b',
'ssh-expired-runtime',
'ssh-fresh-fail',
'ssh-reattach-1',
'ssh-reattach-fail',
'ssh-reattach-ok',
'ssh-runtime-env'
]) {
unregisterSshPtyProvider(leakedConnectionId)
}
setLocalPtyProvider(new LocalPtyProvider())
if (savedProcessPlatform) {
Object.defineProperty(process, 'platform', savedProcessPlatform)
}
if (savedDisableMacosLoginShell !== undefined) {
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = savedDisableMacosLoginShell
} else {
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
}
if (savedOrcaUserDataPath !== undefined) {
process.env.ORCA_USER_DATA_PATH = savedOrcaUserDataPath
} else {
delete process.env.ORCA_USER_DATA_PATH
}
if (savedOpenCodeConfigDir !== undefined) {
process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir
} else {
delete process.env.OPENCODE_CONFIG_DIR
}
if (savedOrcaOpenCodeConfigDir !== undefined) {
process.env.ORCA_OPENCODE_CONFIG_DIR = savedOrcaOpenCodeConfigDir
} else {
delete process.env.ORCA_OPENCODE_CONFIG_DIR
}
if (savedOrcaOpenCodeSourceConfigDir !== undefined) {
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = savedOrcaOpenCodeSourceConfigDir
} else {
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
if (savedPiAgentDir !== undefined) {
process.env.PI_CODING_AGENT_DIR = savedPiAgentDir
} else {
delete process.env.PI_CODING_AGENT_DIR
}
if (savedOrcaPiAgentDir !== undefined) {
process.env.ORCA_PI_CODING_AGENT_DIR = savedOrcaPiAgentDir
} else {
delete process.env.ORCA_PI_CODING_AGENT_DIR
}
if (savedOrcaPiSourceAgentDir === undefined) {
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
} else {
process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir
}
if (savedOrcaCodexHome === undefined) {
delete process.env.ORCA_CODEX_HOME
} else {
process.env.ORCA_CODEX_HOME = savedOrcaCodexHome
}
if (savedOrcaOmpAgentDir !== undefined) {
process.env.ORCA_OMP_CODING_AGENT_DIR = savedOrcaOmpAgentDir
} else {
delete process.env.ORCA_OMP_CODING_AGENT_DIR
}
if (savedOrcaOmpSourceAgentDir !== undefined) {
process.env.ORCA_OMP_SOURCE_AGENT_DIR = savedOrcaOmpSourceAgentDir
} else {
delete process.env.ORCA_OMP_SOURCE_AGENT_DIR
}
if (savedOrcaOmpStatusExtension !== undefined) {
process.env.ORCA_OMP_STATUS_EXTENSION = savedOrcaOmpStatusExtension
} else {
delete process.env.ORCA_OMP_STATUS_EXTENSION
}
if (savedOrcaClaudeAgentStatusSettings === undefined) {
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
} else {
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeAgentStatusSettings
}
})
function createMockProc() {
let dataHandler: ((data: string) => void) | null = null
let exitHandler: ((event: { exitCode: number }) => void) | null = null
return {
proc: {
onData: vi.fn((handler: (data: string) => void) => {
dataHandler = handler
return makeDisposable()
}),
onExit: vi.fn((handler: (event: { exitCode: number }) => void) => {
exitHandler = handler
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn()
},
emitData(data: string) {
dataHandler?.(data)
},
emitExit(exitCode = 0) {
exitHandler?.({ exitCode })
}
}
}
function getPtyWriteListener(): (event: unknown, args: { id: string; data: string }) => void {
const writeCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:write')
if (!writeCall) {
throw new Error('missing pty:write listener')
}
return writeCall[1] as (event: unknown, args: { id: string; data: string }) => void
}
function installDaemonTestProvider(overrides: Record<string, unknown> = {}) {
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
id: options.sessionId ?? 'daemon-pty'
}))
const provider = {
spawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
...overrides
}
setLocalPtyProvider(provider as never)
return spawn
}
function installObservableDaemonTestProvider() {
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
id: options.sessionId ?? 'daemon-pty'
}))
const write = vi.fn()
const pauseProducer = vi.fn()
const resumeProducer = vi.fn()
const shutdown = vi.fn()
let dataHandler: ((payload: { id: string; data: string }) => void) | null = null
let exitHandler: ((payload: { id: string; code: number }) => void) | null = null
let backgroundStreamHandler:
| ((payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void)
| null = null
const getBufferSnapshot = vi.fn()
setLocalPtyProvider({
spawn,
write,
resize: vi.fn(),
pauseProducer,
resumeProducer,
kill: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => {
dataHandler = handler
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onBackgroundStreamEvent: vi.fn(
(handler: (payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) => {
backgroundStreamHandler = handler
return () => {}
}
),
getBufferSnapshot,
onExit: vi.fn((handler: (payload: { id: string; code: number }) => void) => {
exitHandler = handler
return () => {}
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
return {
spawn,
write,
pauseProducer,
resumeProducer,
shutdown,
getBufferSnapshot,
emitData: (id: string, data: string) => dataHandler?.({ id, data }),
emitExit: (id: string, code = 0) => exitHandler?.({ id, code }),
emitDataGap: (id: string, droppedChars: number) =>
backgroundStreamHandler?.({ id, kind: 'dataGap', droppedChars })
}
}
function createAgentClaimProvider(args: {
sessions?: {
id: string
incarnationId?: string
cwd: string
title: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}[]
livePtyIds?: ReadonlySet<string>
spawn?: ReturnType<typeof vi.fn>
authoritativeOwnerListings?: boolean
}) {
return {
spawn: args.spawn ?? vi.fn(async () => ({ id: 'unexpected-spawn' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => args.sessions ?? []),
providesAgentSessionOwnerListings: vi.fn(() => args.authoritativeOwnerListings !== false),
hasPty: vi.fn((id: string) => args.livePtyIds?.has(id) ?? false),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
}
const recoveredAgentClaim = {
digestVersion: 1 as const,
keyId: 'claim-key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const recoveredAgentSurface = {
worktreeId: 'repo-1::/tmp/recovered-worktree',
tabId: '11111111-1111-4111-8111-111111111111',
leafId: '22222222-2222-4222-8222-222222222222',
terminalHandle: 'term_recovered'
}
function registerAgentClaimController(): {
spawn: (args: Record<string, unknown>) => Promise<unknown>
write: (ptyId: string, data: string) => boolean
resize: (ptyId: string, cols: number, rows: number) => boolean
} {
let controller:
| {
spawn: (args: Record<string, unknown>) => Promise<unknown>
write: (ptyId: string, data: string) => boolean
resize: (ptyId: string, cols: number, rows: number) => boolean
}
| undefined
const runtime = {
setPtyController: vi.fn((next) => {
controller = next
}),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
if (!controller) {
throw new Error('PTY controller was not registered')
}
return controller
}
it('fails closed instead of routing encoded SSH PTY writes locally after disconnect', () => {
const connectionId = 'ssh-1'
const ptyId = `ssh:${connectionId}@@remote-pty`
const localProvider = createAgentClaimProvider({})
const sshProvider = createAgentClaimProvider({})
setLocalPtyProvider(localProvider as never)
registerSshPtyProvider(connectionId, sshProvider as never)
setPtyOwnership(ptyId, connectionId)
const controller = registerAgentClaimController()
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
expect(controller.write(ptyId, 'input')).toBe(false)
expect(controller.resize(ptyId, 100, 40)).toBe(false)
expect(localProvider.write).not.toHaveBeenCalled()
expect(localProvider.resize).not.toHaveBeenCalled()
registerSshPtyProvider(connectionId, sshProvider as never)
expect(controller.write(ptyId, 'reconnected')).toBe(true)
expect(controller.resize(ptyId, 120, 50)).toBe(true)
expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected')
expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50)
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(ptyId)
})
it('does not dispatch a runtime PTY spawn after its client disconnects', async () => {
const provider = createAgentClaimProvider({})
setLocalPtyProvider(provider as never)
const controller = registerAgentClaimController()
const abort = new AbortController()
abort.abort()
await expect(
controller.spawn({ cols: 80, rows: 24, cwd: '/tmp/worktree', signal: abort.signal })
).rejects.toThrow('client_disconnected')
expect(provider.spawn).not.toHaveBeenCalled()
})
it('rejects a canonical daemon owner that exited before its spawn reply', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'ededededededededededededededededededededede'
}
const canonicalOwner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-canonical-exited',
phase: 'live',
ptyId: 'pty-canonical-exited',
surface: recoveredAgentSurface
}
const physicalSpawn = vi.fn(async () => ({
id: canonicalOwner.ptyId,
incarnationId: 'incarnation-canonical-exited',
exitedBeforeSpawnReply: true as const,
agentSessionEnsure: { disposition: 'adopted' as const, owner: canonicalOwner }
}))
const provider = createAgentClaimProvider({ spawn: physicalSpawn })
setLocalPtyProvider(provider as never)
let controller: { spawn(args: Record<string, unknown>): Promise<unknown> } | undefined
const runtime = {
setPtyController: vi.fn((next) => {
controller = next
}),
beginPtyRegistration: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await expect(
controller!.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
sessionId: 'different-requested-id',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('agent_session_exited_during_start')
expect(physicalSpawn).toHaveBeenCalledOnce()
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled()
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(
'different-requested-id',
'incarnation-canonical-exited'
)
})
it('rejects renderer spawn publication when the provider reply proves exit', async () => {
const connectionId = 'ssh-renderer-exited-reply'
const appPtyId = `ssh:${connectionId}@@relay-pty`
const provider = {
spawn: vi.fn(async () => ({
id: appPtyId,
incarnationId: 'incarnation-renderer-exited',
exitedBeforeSpawnReply: true as const
})),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_renderer_exited'),
preAllocateHandleForPty: vi.fn(() => 'term_renderer_exited'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerSshPtyProvider(connectionId, provider as never)
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const leafId = '33333333-3333-4333-8333-333333333333'
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp/worktree',
connectionId,
worktreeId: 'repo::/tmp/worktree',
tabId: 'tab-renderer-exited',
leafId
})
).rejects.toThrow('agent_session_exited_during_start')
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(getPtyIdsForConnection(connectionId)).toEqual([])
} finally {
unregisterSshPtyProvider(connectionId)
}
})
it('rejects renderer persistence when a local PTY exits before spawn settles', async () => {
const ptyId = 'pty-renderer-early-exit'
const incarnationId = 'incarnation-renderer-early-exit'
const runtime = new OrcaRuntimeService()
const registerRuntimePty = vi.spyOn(runtime, 'registerPty')
const provider = createAgentClaimProvider({
spawn: vi.fn(async () => {
runtime.onPtySpawned(ptyId, incarnationId)
runtime.onPtyExit(ptyId, 0, incarnationId)
return { id: ptyId, incarnationId }
}),
authoritativeOwnerListings: false
})
const store = { persistPtyBinding: vi.fn() }
setLocalPtyProvider(provider as never)
registerPtyHandlers(
mainWindow as never,
runtime,
undefined,
undefined,
undefined,
store as never
)
const leafId = '44444444-4444-4444-8444-444444444444'
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp/worktree',
worktreeId: 'repo::/tmp/worktree',
tabId: 'tab-renderer-early-exit',
leafId
})
).rejects.toThrow('agent_session_exited_during_start')
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(registerRuntimePty).not.toHaveBeenCalled()
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
clearProviderPtyState(ptyId)
})
it('adopts a live controller-owned local fallback when listings cannot serialize claims', async () => {
const sessions: {
id: string
incarnationId: string
cwd: string
title: string
}[] = []
const physicalSpawn = vi.fn(async () => {
const result = { id: 'pty-local-claim', incarnationId: 'incarnation-local-claim' }
sessions.push({ ...result, cwd: '/tmp/worktree', title: 'Codex' })
return result
})
const provider = createAgentClaimProvider({
sessions,
spawn: physicalSpawn,
authoritativeOwnerListings: false
})
Object.assign(provider, { routesFreshSpawnsToLocalProvider: true })
setLocalPtyProvider(provider as never)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/worktree',
agentSessionEnsure: {
claim: recoveredAgentClaim,
surface: recoveredAgentSurface
}
}
await expect(controller.spawn(request)).resolves.toMatchObject({
agentSessionEnsure: { disposition: 'created' }
})
await expect(controller.spawn(request)).resolves.toMatchObject({
id: 'pty-local-claim',
agentSessionEnsure: { disposition: 'adopted' }
})
expect(physicalSpawn).toHaveBeenCalledOnce()
clearProviderPtyState('pty-local-claim')
})
it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => {
const owner: AgentSessionOwnerBinding = {
claim: recoveredAgentClaim,
generation: 'generation-recovered',
phase: 'live',
ptyId: 'pty-recovered-owner',
surface: recoveredAgentSurface
}
const provider = createAgentClaimProvider({
sessions: [
{
id: owner.ptyId,
incarnationId: 'incarnation-recovered',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
],
livePtyIds: new Set([owner.ptyId])
})
setLocalPtyProvider(provider as never)
const controller = registerAgentClaimController()
const result = await controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface }
})
expect(result).toMatchObject({
id: owner.ptyId,
incarnationId: 'incarnation-recovered',
agentSessionEnsure: { disposition: 'adopted', owner }
})
expect(isCurrentPtyExit({ id: owner.ptyId })).toBe(false)
expect(isCurrentPtyExit({ id: owner.ptyId, incarnationId: 'incarnation-old' })).toBe(false)
expect(isCurrentPtyExit({ id: owner.ptyId, incarnationId: 'incarnation-recovered' })).toBe(true)
expect(provider.spawn).not.toHaveBeenCalled()
clearProviderPtyState(owner.ptyId)
})
it('releases an adopted-owner fence when that owner exits during admission', async () => {
const incarnationId = 'incarnation-adopted-exit'
const owner: AgentSessionOwnerBinding = {
claim: recoveredAgentClaim,
generation: 'generation-adopted-exit',
phase: 'live',
ptyId: 'pty-adopted-exit',
surface: recoveredAgentSurface
}
const runtime = new OrcaRuntimeService()
const provider = createAgentClaimProvider({
sessions: [
{
id: owner.ptyId,
incarnationId,
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
],
livePtyIds: new Set([owner.ptyId])
})
provider.listProcesses.mockImplementation(async () => {
if (provider.listProcesses.mock.calls.length > 1) {
runtime.onPtyExit(owner.ptyId, 0, incarnationId)
}
return [
{
id: owner.ptyId,
incarnationId,
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
]
})
setLocalPtyProvider(provider as never)
registerPtyHandlers(mainWindow as never, runtime)
const controller = (
runtime as unknown as {
ptyController: { spawn(args: Record<string, unknown>): Promise<unknown> }
}
).ptyController
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface }
})
).rejects.toThrow('agent_session_exited_during_start')
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
}
expect(internals.earlyExitedPtyIncarnations.has(owner.ptyId)).toBe(false)
})
it('rejects stale exits immediately after SSH reconnect restores an incarnation', () => {
const ptyId = 'ssh:target-1@@pty-reconnected'
restorePtyIncarnation(ptyId, 'incarnation-current')
expect(isCurrentPtyExit({ id: ptyId, incarnationId: 'incarnation-old' })).toBe(false)
expect(isCurrentPtyExit({ id: ptyId, incarnationId: 'incarnation-current' })).toBe(true)
clearProviderPtyState(ptyId)
})
it('fails closed when a recovered claimed owner omits incarnation proof', async () => {
const owner: AgentSessionOwnerBinding = {
claim: {
...recoveredAgentClaim,
identityDigest: '1212121212121212121212121212121212121212121'
},
generation: 'generation-no-incarnation',
phase: 'live',
ptyId: 'pty-owner-without-incarnation',
surface: recoveredAgentSurface
}
const provider = createAgentClaimProvider({
sessions: [
{
id: owner.ptyId,
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
]
})
setLocalPtyProvider(provider as never)
const controller = registerAgentClaimController()
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim: owner.claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('agent_session_ownership_unknown')
expect(provider.spawn).not.toHaveBeenCalled()
})
it('fails closed without spawning when a recovered owner provider disconnects', async () => {
const connectionId = 'ssh-agent-owner-gone'
const ownerPtyId = `ssh:${connectionId}@@relay-owner`
const claim = {
...recoveredAgentClaim,
identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc'
}
const owner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-remote',
phase: 'live',
ptyId: ownerPtyId,
surface: recoveredAgentSurface
}
const remoteProvider = createAgentClaimProvider({
sessions: [
{
id: ownerPtyId,
incarnationId: 'incarnation-remote',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
],
livePtyIds: new Set([ownerPtyId])
})
registerSshPtyProvider(connectionId, remoteProvider as never)
setLocalPtyProvider(createAgentClaimProvider({}) as never)
const controller = registerAgentClaimController()
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).resolves.toMatchObject({ id: ownerPtyId })
unregisterSshPtyProvider(connectionId)
const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' }))
setLocalPtyProvider(createAgentClaimProvider({ spawn: localSpawn }) as never)
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('execution_owner_unavailable')
expect(localSpawn).not.toHaveBeenCalled()
clearProviderPtyState(ownerPtyId)
})
it('fails closed when provider listings disagree about a recovered claim owner', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'ddddddddddddddddddddddddddddddddddddddddddd'
}
const localOwner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-conflict',
phase: 'live',
ptyId: 'pty-conflict-local',
surface: recoveredAgentSurface
}
const remoteOwner: AgentSessionOwnerBinding = {
...localOwner,
ptyId: 'ssh:ssh-agent-conflict@@pty-conflict-remote'
}
const localSpawn = vi.fn(async () => ({ id: 'must-not-spawn' }))
setLocalPtyProvider(
createAgentClaimProvider({
sessions: [
{
id: localOwner.ptyId,
incarnationId: 'incarnation-conflict-local',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [localOwner]
}
],
spawn: localSpawn
}) as never
)
registerSshPtyProvider(
'ssh-agent-conflict',
createAgentClaimProvider({
sessions: [
{
id: remoteOwner.ptyId,
incarnationId: 'incarnation-conflict-remote',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [remoteOwner]
}
]
}) as never
)
const controller = registerAgentClaimController()
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
})
).rejects.toThrow('agent_session_conflict')
expect(localSpawn).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-agent-conflict')
clearProviderPtyState(localOwner.ptyId)
clearProviderPtyState(remoteOwner.ptyId)
})
it('converges after conflicting listings shrink to one exact owner', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
}
const ownerA: AgentSessionOwnerBinding = {
claim,
generation: 'generation-a',
phase: 'live',
ptyId: 'pty-conflict-a',
surface: recoveredAgentSurface
}
const ownerB: AgentSessionOwnerBinding = {
...ownerA,
generation: 'generation-b',
ptyId: 'ssh:ssh-agent-converge@@pty-conflict-b'
}
const localSessions = [
{
id: ownerA.ptyId,
incarnationId: 'incarnation-conflict-a',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [ownerA]
}
]
const remoteSessions = [
{
id: ownerB.ptyId,
incarnationId: 'incarnation-conflict-b',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [ownerB]
}
]
const local = createAgentClaimProvider({ sessions: localSessions })
setLocalPtyProvider(local as never)
registerSshPtyProvider(
'ssh-agent-converge',
createAgentClaimProvider({ sessions: remoteSessions }) as never
)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
}
await expect(controller.spawn(request)).rejects.toThrow('agent_session_conflict')
localSessions.splice(0)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: ownerB.ptyId,
agentSessionEnsure: { disposition: 'adopted', owner: ownerB }
})
expect(local.spawn).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-agent-converge')
clearProviderPtyState(ownerA.ptyId)
clearProviderPtyState(ownerB.ptyId)
})
it('does not adopt a stale generation when its PTY id is reused without ownership', async () => {
const claim = {
...recoveredAgentClaim,
identityDigest: 'fffffffffffffffffffffffffffffffffffffffffff'
}
const oldOwner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-old',
phase: 'live',
ptyId: 'pty-reused',
surface: recoveredAgentSurface
}
const sessions = [
{
id: oldOwner.ptyId,
incarnationId: 'incarnation-old',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [oldOwner]
}
]
const spawn = vi.fn(
async (options: {
agentSessionEnsure?: { claim: typeof claim; surface: typeof recoveredAgentSurface }
}) => {
const ensured = options.agentSessionEnsure
if (!ensured) {
throw new Error('missing test claim')
}
const owner: AgentSessionOwnerBinding = {
claim: ensured.claim,
generation: 'generation-new',
phase: 'live',
ptyId: 'pty-new-owner',
surface: ensured.surface
}
sessions.push({
id: owner.ptyId,
incarnationId: 'incarnation-new',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
})
return {
id: owner.ptyId,
agentSessionEnsure: { disposition: 'created' as const, owner }
}
}
)
const provider = createAgentClaimProvider({ sessions, spawn })
setLocalPtyProvider(provider as never)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
}
await expect(controller.spawn(request)).resolves.toMatchObject({ id: oldOwner.ptyId })
sessions[0] = { ...sessions[0], agentSessionOwners: [] }
await expect(controller.spawn(request)).resolves.toMatchObject({
id: 'pty-new-owner',
agentSessionEnsure: { disposition: 'created' }
})
expect(spawn).toHaveBeenCalledOnce()
clearProviderPtyState(oldOwner.ptyId)
clearProviderPtyState('pty-new-owner')
})
it('preserves an owner fence across disconnect and adopts it after reconnect', async () => {
const connectionId = 'ssh-agent-reconnect'
const claim = {
...recoveredAgentClaim,
identityDigest: '9999999999999999999999999999999999999999999'
}
const owner: AgentSessionOwnerBinding = {
claim,
generation: 'generation-reconnect',
phase: 'live',
ptyId: `ssh:${connectionId}@@pty-owner`,
surface: recoveredAgentSurface
}
const sessions = [
{
id: owner.ptyId,
incarnationId: 'incarnation-reconnect',
cwd: '/tmp/recovered-worktree',
title: 'Codex',
agentSessionOwners: [owner]
}
]
const firstProvider = createAgentClaimProvider({ sessions })
setLocalPtyProvider(createAgentClaimProvider({}) as never)
registerSshPtyProvider(connectionId, firstProvider as never)
const controller = registerAgentClaimController()
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/recovered-worktree',
connectionId,
agentSessionEnsure: { claim, surface: recoveredAgentSurface }
}
await expect(controller.spawn(request)).resolves.toMatchObject({ id: owner.ptyId })
unregisterSshPtyProvider(connectionId)
clearPtyOwnershipForConnection(connectionId)
await expect(controller.spawn({ ...request, connectionId: undefined })).rejects.toThrow(
'execution_owner_unavailable'
)
const reconnected = createAgentClaimProvider({ sessions })
registerSshPtyProvider(connectionId, reconnected as never)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: owner.ptyId,
agentSessionEnsure: { disposition: 'adopted', owner }
})
expect(reconnected.spawn).not.toHaveBeenCalled()
unregisterSshPtyProvider(connectionId)
clearProviderPtyState(owner.ptyId)
})
function getPtyAckDataListener(): (
event: unknown,
args: { id: string; charCount?: number; processedChars?: number }
) => void {
const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData')
if (!ackCall) {
throw new Error('missing pty:ackData listener')
}
return ackCall[1] as (
event: unknown,
args: { id: string; charCount?: number; processedChars?: number }
) => void
}
function getPtySetActiveRendererPtyListener(): (
event: unknown,
args: { id: string; active: boolean }
) => void {
const activeCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:setActiveRendererPty'
)
if (!activeCall) {
throw new Error('missing pty:setActiveRendererPty listener')
}
return activeCall[1] as (event: unknown, args: { id: string; active: boolean }) => void
}
function getPtySetRendererPtyVisibleListener(): (
event: unknown,
args: { id: string; visible: boolean }
) => void {
const visibleCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:setRendererPtyVisible'
)
if (!visibleCall) {
throw new Error('missing pty:setRendererPtyVisible listener')
}
return visibleCall[1] as (event: unknown, args: { id: string; visible: boolean }) => void
}
function getPtyRendererDispatcherReadyListener(): () => void {
const readyCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:rendererDispatcherReady'
)
if (!readyCall) {
throw new Error('missing pty:rendererDispatcherReady listener')
}
const listener = readyCall[1] as (event: unknown) => void
// Why: the production handler sender-guards its destructive reconcile, so tests must present as the main window.
return () => listener(mainWindowIpcEvent)
}
function getMainWindowWebContentsListener(eventName: string): (...args: unknown[]) => void {
const listenerCall = mainWindow.webContents.on.mock.calls.find(
(call: unknown[]) => call[0] === eventName
)
if (!listenerCall) {
throw new Error(`missing ${eventName} listener`)
}
return listenerCall[1] as (...args: unknown[]) => void
}
function getPtyResizeListener(): (
event: unknown,
args: { id: string; cols: number; rows: number }
) => void {
const resizeCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:resize')
if (!resizeCall) {
throw new Error('missing pty:resize listener')
}
return resizeCall[1] as (
event: unknown,
args: { id: string; cols: number; rows: number }
) => void
}
function getPtySetHiddenRendererPtyListener(): (
event: unknown,
args: { id: string; hidden: boolean }
) => void {
const hiddenCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:setHiddenRendererPty'
)
if (!hiddenCall) {
throw new Error('missing pty:setHiddenRendererPty listener')
}
return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void
}
function getPtySetDeliveryInterestListener(): (
event: unknown,
args: { id: string; interested: boolean }
) => void {
const interestCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest'
)
if (!interestCall) {
throw new Error('missing pty:setPtyDeliveryInterest listener')
}
return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void
}
/** Helper: trigger pty:spawn and return the env passed to node-pty. */
async function spawnAndGetEnv(
argsEnv?: Record<string, string>,
processEnvOverrides?: Record<string, string | undefined>,
getSelectedCodexHomePath?: (
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
agentStatusHooksEnabled?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
// Why: PR #2662 finding 2 — accept an optional `command` so callers can exercise OMP target resolution (was untested).
command?: string,
launchAgent?: TuiAgent,
cwd?: string,
worktreeId?: string
): Promise<Record<string, string>> {
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
for (const [k, v] of Object.entries(processEnvOverrides)) {
savedEnv[k] = process.env[k]
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
try {
// Clear previously registered handlers so re-registration doesn't accumulate stale state.
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
getSelectedCodexHomePath,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...(argsEnv ? { env: argsEnv } : {}),
...(command ? { command } : {}),
...(launchAgent ? { launchAgent } : {}),
...(cwd ? { cwd } : {}),
...(worktreeId ? { worktreeId } : {})
})
const spawnCall = spawnMock.mock.calls.at(-1)!
return spawnCall[2].env as Record<string, string>
} finally {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
}
async function spawnAndGetCall(args?: {
cwd?: string
env?: Record<string, string>
command?: string
}): Promise<[string, string[], { cwd: string; env: Record<string, string> }]> {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...args
})
return spawnMock.mock.calls.at(-1) as [
string,
string[],
{ cwd: string; env: Record<string, string> }
]
}
describe('spawn environment', () => {
it('publishes a lifecycle signal after a successful renderer spawn', async () => {
await spawnAndGetEnv()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: expect.any(String)
})
})
it('marks local Claude launches live until the PTY is killed', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => exitCb?.({ exitCode: -1 })),
process: 'zsh',
pid: 12345
})
const prepareClaudeAuth = vi.fn(async () => ({
configDir: '/tmp/claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'managed:account-1'
}))
registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, prepareClaudeAuth)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'claude'
})) as { id: string }
expect(prepareClaudeAuth).toHaveBeenCalledTimes(1)
expect(hasLiveClaudePtys()).toBe(true)
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(hasLiveClaudePtys()).toBe(false)
})
it('clears Claude live-PTY tracking from shared provider teardown', () => {
markClaudePtySpawned('ssh-claude-pty')
expect(hasLiveClaudePtys()).toBe(true)
clearProviderPtyState('ssh-claude-pty')
expect(hasLiveClaudePtys()).toBe(false)
})
it('defaults LANG to en_US.UTF-8 when not inherited from process.env', async () => {
const env = await spawnAndGetEnv(undefined, { LANG: undefined })
expect(env.LANG).toBe('en_US.UTF-8')
})
it('inherits LANG from process.env when already set', async () => {
const env = await spawnAndGetEnv(undefined, { LANG: 'ja_JP.UTF-8' })
expect(env.LANG).toBe('ja_JP.UTF-8')
})
it('lets caller-provided env override LANG', async () => {
const env = await spawnAndGetEnv({ LANG: 'fr_FR.UTF-8' })
expect(env.LANG).toBe('fr_FR.UTF-8')
})
it('always sets TERM and COLORTERM regardless of env', async () => {
const env = await spawnAndGetEnv()
expect(env.TERM).toBe('xterm-256color')
expect(env.COLORTERM).toBe('truecolor')
expect(env.TERM_PROGRAM).toBe('Orca')
})
it('keeps indexed Git prompt guards in a local agent terminal env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'claude')
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
})
it('guards a trusted local agent when its command uses a custom wrapper', async () => {
const env = await spawnAndGetEnv(
undefined,
undefined,
undefined,
undefined,
'cd /repo && custom-agent-wrapper',
'claude'
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
})
it('advertises OSC 8 hyperlink support via FORCE_HYPERLINK', async () => {
// Why: supports-hyperlinks allowlists TERM_PROGRAM and reports false for Orca, so FORCE_HYPERLINK=1 forces detection on (xterm.js handles OSC 8 natively).
const env = await spawnAndGetEnv()
expect(env.FORCE_HYPERLINK).toBe('1')
})
it('surfaces ORCA_APP_VERSION as TERM_PROGRAM_VERSION for TUI feature gating', async () => {
const env = await spawnAndGetEnv(undefined, { ORCA_APP_VERSION: '1.2.3-test' })
expect(env.TERM_PROGRAM_VERSION).toBe('1.2.3-test')
})
it('falls back to a placeholder version when ORCA_APP_VERSION is unset', async () => {
const env = await spawnAndGetEnv(undefined, { ORCA_APP_VERSION: undefined })
expect(env.TERM_PROGRAM_VERSION).toBe('0.0.0-dev')
})
it('injects the selected Codex home into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('resumes an automatic Codex session from its prepared originating home', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' }))
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: prepareResume }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
expect(prepareResume).toHaveBeenCalledWith(
expect.objectContaining({
providerSession: expect.objectContaining({ id: 'session-a' }),
target: { runtime: 'host' }
})
)
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe('/managed/origin/home')
expect(env.ORCA_CODEX_HOME).toBe('/managed/origin/home')
})
it('overrides an unmarked custom home when the resumed session originated in real home', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
const systemHome = '/Users/example/.codex'
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl'
}
})
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe(systemHome)
expect(env.ORCA_CODEX_HOME).toBe(systemHome)
expect(env.REMOVE_ME).toBeUndefined()
})
it('does not fall back to the selected account when automatic resume provenance is rejected', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{
prepareCodexSessionResume: async () => {
throw new Error('origin unavailable')
}
}
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
).rejects.toThrow('origin unavailable')
expect(selectedHome).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => {
const workspacePath = '/repo/worktrees/new-feature'
const resolveHome = vi.fn(
(
_target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
_launchEnv?: NodeJS.ProcessEnv,
_launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => null
)
await spawnAndGetEnv(
undefined,
undefined,
resolveHome,
undefined,
'codex',
'codex',
workspacePath,
`repo-id::${workspacePath}`
)
expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' })
expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' })
expect(resolveHome.mock.invocationCallOrder[0]).toBeLessThan(
spawnMock.mock.invocationCallOrder[0]!
)
})
it('injects the OpenCode hook env into Orca terminal PTYs', async () => {
// Why: clear any ambient OPENCODE_CONFIG_DIR so the mock's value is used
const env = await spawnAndGetEnv(undefined, { OPENCODE_CONFIG_DIR: undefined })
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(openCodeBuildPtyEnvMock.mock.calls[0]?.[0]).toEqual(expect.any(String))
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
expect(env.ORCA_OPENCODE_HOOK_TOKEN).toBe('opencode-token')
expect(env.ORCA_OPENCODE_PTY_ID).toBe('test-pty')
expect(env.OPENCODE_CONFIG_DIR).toEqual(expect.any(String))
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR)
})
it('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-opencode-config'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/tmp/user-opencode-config')
})
it('does not treat inherited Orca OpenCode config as user config without a source dir', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('restores user OpenCode config when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/user-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('strips inherited OpenCode overlay env when agent status hooks are disabled without a source dir', async () => {
const env = await spawnAndGetEnv(
{
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
it('injects MiMo overlay env only when launch command is mimo', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'mimo')
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
})
it.each(['/usr/local/bin/mimo --prompt hi', '"C:\\Program Files\\MiMo\\mimo.cmd" --prompt hi'])(
'injects MiMo overlay env for path-qualified launch command %s',
async (launchCommand) => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, launchCommand)
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
}
)
it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => {
const env = await spawnAndGetEnv(
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' },
undefined,
undefined,
undefined,
'bash -lc wait-wrapper'
)
expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
})
it('does not inject MiMo overlay for non-mimo launches', async () => {
await spawnAndGetEnv()
expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled()
})
it('restores user MiMo home when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay',
ORCA_MIMOCODE_HOME: '/tmp/parent-orca-mimocode-overlay',
ORCA_MIMOCODE_SOURCE_HOME: '/tmp/user-mimocode-home'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false }),
'mimo'
)
expect(mimoCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.MIMOCODE_HOME).toBe('/tmp/user-mimocode-home')
expect(env.ORCA_MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
})
posixOnlyIt(
'reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config',
async () => {
// Why: the reporter's app didn't inherit OPENCODE_CONFIG_DIR; their interactive zsh later exported a company config repo.
readFileSyncMock.mockImplementation((path: string) => {
if (path.endsWith('.zshrc')) {
return [
'# Company-wide OpenCode config loaded by interactive shells',
'export OPENCODE_CONFIG_DIR="$HOME/company/opencode-config"',
''
].join('\n')
}
return ''
})
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/pim/company/opencode-config'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/home/pim/company/opencode-config')
expect(env.OPENCODE_CONFIG_DIR).not.toBe(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR)
}
)
it('installs Pi managed extensions without redirecting Orca terminal PTY homes', async () => {
const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' })
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/default-omp-agent/extensions/orca-agent-status.ts'
)
})
it('threads command: "omp" through to piBuildPtyEnv and emits OMP status metadata', async () => {
// Why: OMP launches emit ORCA_OMP_* shadow vars, not Pi-named ones; only PI_CODING_AGENT_DIR stays (OMP's own binary reads it).
const env = await spawnAndGetEnv(
undefined,
{ PI_CODING_AGENT_DIR: '/tmp/user-omp-agent' },
undefined,
undefined,
'omp'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-omp-agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-omp-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/user-omp-agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent')
// CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env.
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/user-omp-agent',
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
},
undefined,
undefined,
undefined,
'powershell wait-wrapper'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-omp-agent',
'omp'
)
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/user-omp-agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/parent-orca-pi-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
})
it('does not use an inherited Pi overlay source for an OMP launch', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
},
undefined,
undefined,
undefined,
'omp'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/default-omp-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('does not use an inherited OMP overlay source for an explicit Pi launch', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay',
ORCA_OMP_CODING_AGENT_DIR: '/tmp/parent-orca-omp-overlay',
ORCA_OMP_SOURCE_AGENT_DIR: '/tmp/user-omp-agent'
},
undefined,
undefined,
undefined,
'pi'
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/default-pi-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBeUndefined()
})
it('restores user Pi config when agent status hooks are disabled in a nested Orca shell', async () => {
const env = await spawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
},
undefined,
undefined,
() => ({ agentStatusHooksEnabled: false })
)
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
posixOnlyIt(
'uses Pi config exported only by shell startup files as the managed extension target',
async () => {
readFileSyncMock.mockImplementation((path: string) =>
path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : ''
)
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/tester',
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: undefined
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/tester/.config/pi-agent',
'pi'
)
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent')
}
)
it('injects the agent hook receiver env into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv()
// Why: buildAgentHookEnv must run exactly once per local spawn (inside shared buildPtyHostEnv); the old ad-hoc double-call is gone.
expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1)
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips stale inherited hook receiver env before injecting this runtime', async () => {
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('does not leak inherited hook receiver env if the hook server is unavailable', async () => {
buildAgentHookEnvMock.mockReturnValueOnce({})
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env',
ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('skips git/gh attribution shims when attribution is disabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
const daemonSpawn = vi.fn(async (options) => ({ id: 'daemon-pty', pid: 123, ...options }))
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {}
})
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
const env = await spawnAndGetEnv(
undefined,
{ CODEX_HOME: '/tmp/system-codex-home' },
() => TEST_CODEX_HOME
)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
// Why: flag OFF must stay byte-identical to today. With no managed home
// selected (resolver null) and the real-home flag off, no CODEX_HOME
// injection or strip happens; an inherited value survives as before.
const env = await spawnAndGetEnv(
undefined,
{ CODEX_HOME: '/tmp/system-codex-home' },
() => null
)
expect(env.CODEX_HOME).toBe('/tmp/system-codex-home')
})
it('strips a nested-Orca override for system default when the real-home flag is ON', async () => {
const env = await spawnAndGetEnv(
{ CODEX_HOME: '/managed/home', ORCA_CODEX_HOME: '/managed/home' },
undefined,
() => null,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never
)
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
})
it('preserves a user-owned CODEX_HOME for system default when the real-home flag is ON', async () => {
const env = await spawnAndGetEnv(
{ CODEX_HOME: '/home/me/.config/codex' },
{ ORCA_CODEX_HOME: undefined },
() => null,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never
)
expect(env.CODEX_HOME).toBe('/home/me/.config/codex')
expect(env.ORCA_CODEX_HOME).toBeUndefined()
})
it('lets the resolver keep a per-spawn custom CODEX_HOME on the managed lane', async () => {
const customHome = '/home/me/.config/codex'
let resolvedCodexHome: string | undefined
const resolveHome = vi.fn((_target: unknown, launchEnv?: NodeJS.ProcessEnv) => {
resolvedCodexHome = launchEnv?.CODEX_HOME
return launchEnv?.CODEX_HOME === customHome ? TEST_CODEX_HOME : null
})
const env = await spawnAndGetEnv(
{ CODEX_HOME: customHome },
{ CODEX_HOME: undefined, ORCA_CODEX_HOME: undefined },
resolveHome,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never
)
expect(resolveHome).toHaveBeenCalledTimes(1)
expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' })
expect(resolvedCodexHome).toBe(customHome)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('injects explicit proxy settings into local PTY env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost,*.internal'
}))
expect(env.HTTP_PROXY).toBe('http://proxy.example:8080')
expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080')
expect(env.ALL_PROXY).toBe('http://proxy.example:8080')
expect(env.NO_PROXY).toBe('localhost,*.internal')
})
describe('daemon-active provider (parity with LocalPtyProvider)', () => {
// Why: under the daemon, LocalPtyProvider.buildSpawnEnv never runs, so host-local env injection must happen in the pty:spawn handler instead.
function setupDaemonAdapter(
supportsGitCredentialGuardHost = true,
reportedWslDistro?: string | null,
supportsAgentSessionClaims = true,
supportsAgentSessionCreateOperations = supportsAgentSessionClaims
) {
const daemonSpawn = vi.fn(
async (options: {
env: Record<string, string>
sessionId?: string
isNewSession?: boolean
agentSessionCreateOperationId?: string
command?: string
}) => ({
id: options.sessionId ?? 'daemon-pty',
...(reportedWslDistro !== undefined ? { wslDistro: reportedWslDistro } : {})
})
)
setLocalPtyProvider({
spawn: daemonSpawn,
supportsGitCredentialGuardHost: () => supportsGitCredentialGuardHost,
supportsAgentSessionClaims: () => supportsAgentSessionClaims,
supportsAgentSessionCreateOperations: () => supportsAgentSessionCreateOperations,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
return daemonSpawn
}
type DaemonSpawnCall = {
env: Record<string, string>
envToDelete?: string[]
isNewSession?: boolean
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: string
}
async function withWin32Platform<T>(fn: () => Promise<T>): Promise<T> {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
return await fn()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
}
function makeProjectRuntimeStore(args: {
projectRuntimePreference: unknown
settings?: Record<string, unknown>
}) {
const settings = {
localWindowsRuntimeDefault: { kind: 'windows-host' },
...args.settings
}
return {
getRepo: vi.fn((repoId: string) =>
repoId === 'repo-1' ? { id: 'repo-1', path: 'C:\\repo' } : undefined
),
getProjects: vi.fn(() => [
{
id: 'project-1',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: args.projectRuntimePreference
}
]),
getSettings: vi.fn(() => settings)
}
}
async function daemonSpawnAndGetOptions(
argsEnv?: Record<string, string>,
getSelectedCodexHomePath?: (
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
processEnvOverrides?: Record<string, string | undefined>,
// Why: daemon spawn tests exercise both WSL launch metadata from main and PR #2662 command threading for OMP.
spawnArgs?: {
cwd?: string
worktreeId?: string
shellOverride?: string
command?: string
launchAgent?: TuiAgent
envToDelete?: string[]
},
supportsGitCredentialGuardHost = true
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter(supportsGitCredentialGuardHost)
const savedEnv: Record<string, string | undefined> = {}
if (processEnvOverrides) {
for (const [k, v] of Object.entries(processEnvOverrides)) {
savedEnv[k] = process.env[k]
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
try {
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
getSelectedCodexHomePath,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...spawnArgs,
...(argsEnv ? { env: argsEnv } : {})
})
return daemonSpawn.mock.calls.at(-1)![0] as DaemonSpawnCall
} finally {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) {
delete process.env[k]
} else {
process.env[k] = v
}
}
}
}
async function daemonSpawnAndGetEnv(
argsEnv?: Record<string, string>,
getSelectedCodexHomePath?: (
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
processEnvOverrides?: Record<string, string | undefined>,
spawnArgs?: {
cwd?: string
shellOverride?: string
command?: string
launchAgent?: TuiAgent
},
supportsGitCredentialGuardHost = true
): Promise<Record<string, string>> {
return (
await daemonSpawnAndGetOptions(
argsEnv,
getSelectedCodexHomePath,
getSettings,
processEnvOverrides,
spawnArgs,
supportsGitCredentialGuardHost
)
).env
}
it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
OPENCODE_CONFIG_DIR: undefined
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalled()
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
})
it('mirrors a user-provided OPENCODE_CONFIG_DIR into a source-scoped overlay on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' })
// Why: OpenCode loads config from a single dir, so the user's path is mirrored into a source-scoped overlay, not passed through.
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode')
})
it('uses source OpenCode config env instead of remirroring a parent overlay', async () => {
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode'
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode')
})
it('installs Pi managed extensions without redirecting homes on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ PI_CODING_AGENT_DIR: '/user/.pi/agent' })
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.PI_CODING_AGENT_DIR).toBe('/user/.pi/agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension)
})
it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP status metadata', async () => {
// Why: mirror of the local-spawn OMP threading assertion; the daemon path's `command` forwarding could silently regress otherwise.
const env = await daemonSpawnAndGetEnv(
{ PI_CODING_AGENT_DIR: '/user/.omp/agent' },
undefined,
undefined,
undefined,
{ command: 'omp' }
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/.omp/agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/user/.omp/agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/user/.omp/agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/user/.omp/agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => {
const env = await daemonSpawnAndGetEnv(
{
PI_CODING_AGENT_DIR: '/user/.omp/agent',
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
},
undefined,
undefined,
undefined,
{ command: 'powershell wait-wrapper' }
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/.omp/agent',
'omp'
)
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/user/.omp/agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
it('injects the selected Codex home on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('overrides an unmarked custom home for an authoritative daemon resume', async () => {
const daemonSpawn = setupDaemonAdapter()
const selectedHome = vi.fn(() => '/managed/current/home')
const systemHome = '/Users/example/.codex'
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl`
}
})
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe(systemHome)
expect(env.ORCA_CODEX_HOME).toBe(systemHome)
expect(env.REMOVE_ME).toBeUndefined()
})
it('keeps the authoritative home for runtime-created daemon resumes', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
command: string
env: Record<string, string>
envToDelete: string[]
launchAgent: 'codex'
resumeProviderSession: {
key: 'session_id'
id: string
transcriptPath: string
}
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const systemHome = '/Users/example/.codex'
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl`
}
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.CODEX_HOME).toBe(systemHome)
expect(spawnOptions.env.ORCA_CODEX_HOME).toBe(systemHome)
expect(spawnOptions.env.REMOVE_ME).toBeUndefined()
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(spawnOptions.envToDelete).toContain('REMOVE_ME')
})
it('prepares Codex project trust before a daemon-backed interactive launch', async () => {
const workspacePath = '/repo/worktrees/new-feature'
const resolveHome = vi.fn(
(
_target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null },
_launchEnv?: NodeJS.ProcessEnv,
_launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => null
)
await daemonSpawnAndGetOptions({}, resolveHome, undefined, undefined, {
cwd: workspacePath,
worktreeId: `repo-id::${workspacePath}`,
command: 'codex',
launchAgent: 'codex'
})
expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' })
expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' })
})
it('injects explicit proxy settings on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost;*.internal'
}))
expect(env.HTTP_PROXY).toBe('http://proxy.example:8080')
expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080')
expect(env.NO_PROXY).toBe('localhost,*.internal')
})
it('skips host Codex home when a daemon-backed Windows spawn targets a WSL cwd', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
undefined,
{
CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
{
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\test\\repo'
}
)
const { env } = spawnOptions
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('skips host Codex home when a daemon-backed Windows spawn uses a WSL shell override', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
undefined,
{
CODEX_HOME: 'C:\\Users\\test\\.codex',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
{ shellOverride: 'wsl.exe' }
)
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME'])
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('drops OPENCODE_CONFIG_DIR for a WSL daemon spawn until the guest overlay is known', async () => {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, {
shellOverride: 'wsl.exe'
})
// Why: relay not connected yet → never cross the Windows overlay path into WSL.
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
})
it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => {
const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc'
const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir)
try {
await withWin32Platform(async () => {
const env = await daemonSpawnAndGetEnv(
{ ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/jin/.config/opencode' },
undefined,
undefined,
undefined,
{ shellOverride: 'wsl.exe' }
)
expect(env.OPENCODE_CONFIG_DIR).toBe(guestDir)
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(guestDir)
// The Windows-side source pointer must not cross into the guest.
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
})
} finally {
spy.mockRestore()
}
})
it('strips the daemon-inherited Orca-owned CODEX_HOME for real-home routing', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => null,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never,
{ CODEX_HOME: '/managed/home', ORCA_CODEX_HOME: '/managed/home' }
)
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME']))
// The daemon compares its own merged values before deleting CODEX_HOME.
expect(spawnOptions.envToDelete).not.toContain('CODEX_HOME')
})
it('preserves a daemon-inherited user CODEX_HOME for real-home routing', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{},
() => null,
() => ({ codexSystemDefaultRealHomeEnabled: true }) as never,
{ CODEX_HOME: '/home/me/.config/codex', ORCA_CODEX_HOME: undefined }
)
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME']))
expect(spawnOptions.envToDelete).not.toEqual(expect.arrayContaining(['CODEX_HOME']))
})
it('does not strip the daemon-inherited CODEX_HOME when the flag is OFF', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({}, () => null, undefined, {
CODEX_HOME: '/managed/home',
ORCA_CODEX_HOME: '/managed/home'
})
expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME']))
})
it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
try {
// Why: overriding process.platform doesn't change the loaded node:path dialect; keep this synthetic PATH consistent.
const env = await daemonSpawnAndGetEnv({
PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter)
})
const entries = env.PATH.split(delimiter)
const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim')
// Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904).
expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0)
expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin'))
expect(env.ORCA_CLI_COMMAND).toBeUndefined()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('injects the agent-hook receiver env on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('deletes stale Claude scoped settings env from daemon-hosted PTYs', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({}, undefined, undefined, {
ORCA_CLAUDE_AGENT_STATUS_SETTINGS:
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
})
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS =
'/tmp/orca/agent-hooks/claude-agent-status-settings.json'
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['ORCA_CLAUDE_AGENT_STATUS_SETTINGS'])
)
expect(spawnOptions.env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
tabId?: string
leafId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
tabId: 'tab-1',
leafId
})
// Why: runtime-created spawns must thread {tabId, leafId} so the catch-path rescue can keep their live PTY (#7587).
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-runtime',
null,
{ tabId: 'tab-1', leafId },
false
)
})
it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => {
await withWin32Platform(async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const settings = {
localWindowsRuntimeDefault: { kind: 'windows-host' },
terminalWindowsShell: 'powershell.exe',
terminalWindowsWslDistro: 'Debian',
terminalWindowsPowerShellImplementation: 'auto'
}
const store = makeProjectRuntimeStore({
projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
settings
})
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
(() => settings) as never,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
await controller.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\repo',
worktreeId: 'repo-1::C:\\repo',
env: {}
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.shellOverride).toBe('wsl.exe')
expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu')
expect(spawnOptions.terminalWindowsPowerShellImplementation).toBe('auto')
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'repo-1::C:\\repo',
null,
undefined,
true
)
})
})
it('distinguishes an attached native context from an older daemon fallback', async () => {
await withWin32Platform(async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
const settings = {
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu',
terminalWindowsPowerShellImplementation: 'auto'
}
const cases: {
reportedWslDistro: string | null | undefined
expectedWslDistro: string | null
sessionId: string
}[] = [
{
reportedWslDistro: null,
expectedWslDistro: null,
sessionId: 'native-session'
},
{
reportedWslDistro: undefined,
expectedWslDistro: 'Ubuntu',
sessionId: 'older-daemon-session'
}
]
for (const testCase of cases) {
setupDaemonAdapter(true, testCase.reportedWslDistro)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
preparePtyExecutionContext: vi.fn().mockReturnValue(true)
}
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
(() => settings) as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: testCase.sessionId,
cwd: '\\\\server\\share\\repo'
})
expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith(
testCase.sessionId,
testCase.expectedWslDistro
)
}
})
})
it('blocks runtime-created daemon PTYs when project WSL runtime requires repair', async () => {
await withWin32Platform(async () => {
_setWslCachesForTests({ available: true, distros: ['Debian'] })
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const settings = {
localWindowsRuntimeDefault: { kind: 'windows-host' },
terminalWindowsShell: 'powershell.exe'
}
const store = makeProjectRuntimeStore({
projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
settings
})
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
(() => settings) as never,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\repo',
worktreeId: 'repo-1::C:\\repo',
env: {}
})
).rejects.toThrow(
'Project runtime requires repair before terminal spawn: wsl-distro-missing'
)
expect(daemonSpawn).not.toHaveBeenCalled()
})
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
} finally {
mockedApp.isPackaged = prev
}
})
it('prepends attribution shims on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
undefined,
() => ({ enableGitHubAttribution: true }),
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
)
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
// Why: the mocked `app` is a plain object, so we can flip isPackaged for the test's scope.
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' })
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.PATH).toContain(join('/tmp/orca-user-data', 'cli', 'bin'))
} finally {
mockedApp.isPackaged = prev
}
})
it('preserves the inherited PATH when dev-mode daemon env omits PATH', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PATH: '/system/bin'
})
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.PATH).toContain(
`${join('/tmp/orca-user-data', 'cli', 'bin')}${delimiter}/system/bin`
)
} finally {
mockedApp.isPackaged = prev
}
})
it('defers indexed Git prompt guards from the daemon wire environment', async () => {
const env = await daemonSpawnAndGetEnv(
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
},
undefined,
undefined,
undefined,
{ command: 'claude' }
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
})
it('materializes the full guard for a legacy daemon host', async () => {
const env = await daemonSpawnAndGetEnv(
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
},
undefined,
undefined,
undefined,
{ command: 'claude' },
false
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
})
it('passes the minted sessionId through to provider.spawn and host env setup', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {}
})
const spawnOpts = daemonSpawn.mock.calls.at(-1)![0]
const sessionId = spawnOpts.sessionId
expect(sessionId).toEqual(expect.any(String))
expect((sessionId ?? '').length).toBeGreaterThan(0)
expect(spawnOpts.isNewSession).toBe(true)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi')
})
it('respects a caller-provided sessionId instead of minting a new one', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: 'user-session-42'
})
expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42')
expect(daemonSpawn.mock.calls.at(-1)![0].isNewSession).toBeUndefined()
expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi')
})
it('prefixes a minted sessionId with the worktreeId when provided', async () => {
// Why: daemon reconnect keys live-shell survival on the sessionId; prefixing with worktreeId scopes sessions by worktree with a unique tail.
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
worktreeId: 'wt-alpha'
})
const sessionId = daemonSpawn.mock.calls.at(-1)![0].sessionId ?? ''
expect(sessionId).toMatch(/^wt-alpha@@[0-9a-f]{8}$/)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi')
})
it('reuses one attach-style daemon session for fresh-agent operation retries', async () => {
const daemonSpawn = setupDaemonAdapter()
let controller:
| { spawn: (args: Record<string, unknown>) => Promise<{ id: string }> }
| undefined
const runtime = {
setPtyController: vi.fn((next) => {
controller = next
}),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const request = {
cols: 80,
rows: 24,
env: {},
worktreeId: 'wt-alpha',
agentSessionCreateOperationId: 'a'.repeat(43)
}
await controller!.spawn(request)
await controller!.spawn(request)
const first = daemonSpawn.mock.calls.at(-2)?.[0]
const second = daemonSpawn.mock.calls.at(-1)?.[0]
expect(first?.sessionId).toBe('wt-alpha@@aaaaaaaa')
expect(second?.sessionId).toBe(first?.sessionId)
expect(first?.isNewSession).toBe(true)
expect(second?.isNewSession).toBe(true)
expect(first?.agentSessionCreateOperationId).toBe('a'.repeat(43))
expect(second?.agentSessionCreateOperationId).toBe('a'.repeat(43))
})
it('does not downgrade a structured claim after dispatch reaches an old daemon', async () => {
const daemonSpawn = setupDaemonAdapter(true, undefined, false)
let controller:
| { spawn: (args: Record<string, unknown>) => Promise<{ id: string }> }
| undefined
const runtime = {
setPtyController: vi.fn((next) => {
controller = next
}),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await expect(
controller!.spawn({
cols: 80,
rows: 24,
worktreeId: recoveredAgentSurface.worktreeId,
tabId: recoveredAgentSurface.tabId,
leafId: recoveredAgentSurface.leafId,
command: "codex resume 'provider-session-1'",
agentSessionEnsure: {
claim: recoveredAgentClaim,
surface: recoveredAgentSurface
}
})
).rejects.toThrow('agent_session_claim_unavailable')
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('falls back to process.env.PI_CODING_AGENT_DIR when baseEnv lacks it on the daemon path', async () => {
// Why: buildPtyHostEnv reads `baseEnv.X ?? process.env.X` so the agent-dir guard works whether Pi's env came over IPC or via daemon fork.
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PI_CODING_AGENT_DIR: '/ambient/pi/agent'
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/ambient/pi/agent',
'pi'
)
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/ambient/pi/agent')
})
it('skips attribution shims on the daemon path when the setting is disabled', async () => {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('does not mutate the caller-provided args.env on the daemon path', async () => {
// Why: the handler clones baseEnv so IPC-provided env stays pristine; a regression would leak Orca host env back into the renderer's reused copy.
const daemonSpawn = setupDaemonAdapter()
const argsEnv: Record<string, string> = { FOO: 'bar' }
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: argsEnv
})
expect(argsEnv).toEqual({ FOO: 'bar' })
// Sanity: the spawn did receive the injected env, so the test isn't passing vacuously.
const spawnEnv = daemonSpawn.mock.calls.at(-1)![0].env
expect(spawnEnv.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(spawnEnv).not.toBe(argsEnv)
})
it('rejects a caller-supplied sessionId that escapes userData via ..', async () => {
// Why: effectiveSessionId reaches filesystem side-effects, so a traversal payload must be refused before they run.
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: '../etc/passwd'
})
).rejects.toThrow(/Invalid PTY session id/)
expect(daemonSpawn).not.toHaveBeenCalled()
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
})
it('sweeps per-PTY state when provider.spawn fails for a MINTED sessionId', async () => {
// Why: buildPtyHostEnv has filesystem side-effects, so a minted id's per-PTY state must be cleared if provider.spawn later fails.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn(),
preparePtyExecutionContext: vi.fn().mockReturnValue(true)
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await expect(
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
).rejects.toThrow(/spawn boom/)
expect(openCodeClearPtyMock).toHaveBeenCalled()
expect(piClearPtyMock).toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith(
expect.any(String),
null,
{ resetIncarnation: true }
)
})
it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => {
// Why: a caller-supplied sessionId may refer to an existing PTY whose state must survive a retry/attach failure; only minted ids get swept.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn(),
preparePtyExecutionContext: vi.fn().mockReturnValue(true)
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
sessionId: 'caller-owned-session'
})
).rejects.toThrow(/spawn boom/)
expect(openCodeClearPtyMock).not.toHaveBeenCalled()
expect(piClearPtyMock).not.toHaveBeenCalled()
expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith(
'caller-owned-session',
null,
{ resetIncarnation: true }
)
})
it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => {
const sshSpawn = vi.fn(
async (_opts: {
env: Record<string, string>
envToDelete?: string[]
paneKey?: string
tabId?: string
}) => ({
id: 'ssh-pty'
})
)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
(() => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost',
codexSystemDefaultRealHomeEnabled: true
})) as never,
undefined,
store as never
)
const leafId = '11111111-1111-4111-8111-111111111111'
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { FOO: 'bar', ORCA_PANE_KEY: makePaneKey('tab-1', leafId) },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId
})
const spawnOptions = sshSpawn.mock.calls.at(-1)![0]
const env = spawnOptions.env
// Why: host-local vars must be absent over SSH (they point at the local host/disk) — shipping them is useless or a credential leak.
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
expect(env.MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_HOME).toBeUndefined()
expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined()
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.CODEX_HOME).toBeUndefined()
expect(env.HTTP_PROXY).toBeUndefined()
expect(env.HTTPS_PROXY).toBeUndefined()
expect(env.NO_PROXY).toBeUndefined()
expect(env.FOO).toBe('bar')
// Why: real-home routing is host-only. A null local-home resolver on
// SSH must not become a request to alter the remote Codex environment.
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(spawnOptions.paneKey).toBe(makePaneKey('tab-1', leafId))
expect(spawnOptions.tabId).toBe('tab-1')
expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled()
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'ssh-pty',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
ptyId: 'ssh-pty'
},
'ssh:ssh-1'
)
store.upsertSshRemotePtyLease.mockClear()
store.persistPtyBinding.mockClear()
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { ORCA_PANE_KEY: 'tab-1:pane:1' },
connectionId: 'ssh-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledTimes(1)
const legacySpawnOptions = sshSpawn.mock.calls.at(-1)?.[0]
expect(legacySpawnOptions?.env.ORCA_PANE_KEY).toBeUndefined()
expect(legacySpawnOptions?.paneKey).toBeUndefined()
expect(legacySpawnOptions?.tabId).toBe('tab-1')
expect(store.upsertSshRemotePtyLease.mock.calls[0]?.[0]).not.toHaveProperty('leafId')
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('marks a caller-supplied SSH session expired when remote reattach is gone', async () => {
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: 'remote-pty'
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
})
it('marks a scoped SSH session expired using the raw relay lease id', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const sshSpawn = vi.fn(async () => {
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
})
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership(scopedPtyId, 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: scopedPtyId
})
).rejects.toThrow('SSH_SESSION_EXPIRED: remote-pty')
} finally {
deletePtyOwnership(scopedPtyId)
}
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired')
expect(openCodeClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(scopedPtyId)
})
it('does not clear a scoped SSH session when remote reattach rejects an identity mismatch', async () => {
const scopedPtyId = 'ssh:ssh-1@@remote-pty'
const remoteWrite = vi.fn()
const sshSpawn = vi.fn(async () => {
throw new Error(
`${SSH_SESSION_EXPIRED_ERROR}: remote-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
})
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership(scopedPtyId, 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {},
connectionId: 'ssh-1',
sessionId: scopedPtyId
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'expired'
)
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
expect(piClearPtyMock).not.toHaveBeenCalledWith(scopedPtyId)
getPtyWriteListener()(mainWindowIpcEvent, {
id: scopedPtyId,
data: 'echo still-owned'
})
expect(remoteWrite).toHaveBeenCalledWith(scopedPtyId, 'echo still-owned')
} finally {
deletePtyOwnership(scopedPtyId)
}
})
it('does not tombstone an SSH lease when explicit kill shutdown fails transiently', async () => {
const store = {
markSshRemotePtyLease: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
try {
await expect(
handlers.get('pty:kill')!(null, { id: 'remote-pty', keepHistory: false })
).rejects.toThrow('Multiplexer disposed')
} finally {
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
})
it('marks an SSH lease terminated after runtime controller kill succeeds', async () => {
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
// Why: kill's shutdown runs through the exit-detection wrapper (extra async hops), so one microtask flush isn't enough.
await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false })
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => {
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const shutdown = vi.fn(async (id: string) => {
for (const listener of exitListeners) {
listener({ id, code: 0 })
}
})
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('local-pty')).toBe(true)
await Promise.resolve()
await Promise.resolve()
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
})
it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => {
vi.useFakeTimers()
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const shutdown = vi.fn(async (id: string) => {
for (const listener of exitListeners) {
listener({ id, code: 0 })
}
})
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('local-pty')
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
})
it('classifies host reversible-stop exits for the attached renderer', async () => {
vi.useFakeTimers()
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(async (id: string) => {
for (const listener of exitListeners) {
listener({ id, code: 0 })
}
}),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
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<boolean>
}
const release = controller.markReversibleStops(['local-pty'])
const stopPromise = controller.stopAndWait('local-pty')
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
release()
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0, preserveRendererBinding: true }]])
})
it('passes keepHistory through runtime controller stopAndWait', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('remote-pty', { keepHistory: true })
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
expect(shutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: true
})
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => {
// Why: sequential RPCs must share one absolute deadline; otherwise both get
// the full ~9.5s bound and their sum overruns the 10s sweep deadline (Finding 1).
// Fake timers freeze Date.now() at entry, then let the shutdown RPC burn a
// deterministic slice of the budget so the leaf-observed remainders are provable.
vi.useFakeTimers()
// Each provider call records the budget an RPC leaf would see at issue time.
const remainingAtLeaf: number[] = []
const shutdown = vi.fn(async (_id: string, opts?: { deadlineMs?: number }) => {
remainingAtLeaf.push((opts?.deadlineMs ?? 0) - Date.now())
await new Promise<void>((resolve) => setTimeout(resolve, 1000))
})
const listProcesses = vi.fn(async (opts?: { deadlineMs?: number }) => {
remainingAtLeaf.push((opts?.deadlineMs ?? 0) - Date.now())
return []
})
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
) => Promise<boolean>
}
const deadlineMs = Date.now() + 4321
const stopPromise = controller.stopAndWait('local-pty', { deadlineMs })
await vi.advanceTimersByTimeAsync(1000)
await expect(stopPromise).resolves.toBe(true)
// Both calls carry the same absolute deadline...
expect(shutdown).toHaveBeenCalledWith(
'local-pty',
expect.objectContaining({ immediate: true, deadlineMs })
)
// ...so at the leaves the shutdown RPC sees the full 4321ms budget, while the
// SUBSEQUENT liveness list RPC sees only what shutdown left: the 1000ms it
// consumed is gone, so 4321 - 1000 = 3321 remain until the shared deadline.
expect(remainingAtLeaf).toEqual([4321, 3321])
vi.useRealTimers()
})
it('runtime controller stopAndWait fails when keepHistory allows the PTY to revive', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
const listProcesses = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'local-pty', cwd: '/tmp/demo', title: 'shell' }])
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('local-pty', { keepHistory: true })
await vi.advanceTimersByTimeAsync(200)
await expect(stopPromise).resolves.toBe(false)
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('runtime controller stopAndWait preserves ownership when proof fails after shutdown', async () => {
const shutdown = vi.fn(async () => undefined)
const listProcesses = vi.fn().mockRejectedValue(new Error('legacy unavailable'))
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
await expect(controller.stopAndWait('local-pty', { keepHistory: true })).resolves.toBe(
false
)
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('does not accept an incarnation-less exit as proof that the current PTY stopped', async () => {
const exitListeners = new Set<
(payload: { id: string; code: number; incarnationId?: string }) => void
>()
const provider = {
spawn: vi.fn(async () => ({ id: 'local-incarnated', incarnationId: 'incarnation-live' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(async () => {
for (const listener of exitListeners) {
listener({ id: 'local-incarnated', code: 0 })
}
}),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
setLocalPtyProvider(provider as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn: (args: { cols: number; rows: number }) => Promise<{ id: string }>
stopAndWait: (ptyId: string) => Promise<boolean>
}
await controller.spawn({ cols: 80, rows: 24 })
await expect(controller.stopAndWait('local-incarnated')).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', -1, 'incarnation-live')
})
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const shutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn() }
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false })
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('runtime controller kill tombstones app-scoped SSH ids when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = { markSshRemotePtyLease: vi.fn() }
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1, undefined)
})
it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => {
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('remote-pty')).toBe(true)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Multiplexer disposed')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
try {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: 'tab-1:0',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
connectionId: 'ssh-1'
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.FOO).toBe('bar')
expect(env.ORCA_PANE_KEY).toBeUndefined()
expect(env.ORCA_TAB_ID).toBeUndefined()
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
// Why: the local hook server's userData-relative endpoint path is meaningless on the remote box; assert no leak.
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH by default', async () => {
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
id: 'ssh-pty'
}))
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
try {
const leafId = '22222222-2222-4222-8222-222222222222'
const paneKey = makePaneKey('tab-2', leafId)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-2',
ORCA_WORKTREE_ID: 'wt-2'
},
connectionId: 'ssh-1',
tabId: 'tab-2',
leafId
})
const env = sshSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_PANE_KEY).toBe(paneKey)
expect(env.ORCA_TAB_ID).toBe('tab-2')
expect(env.ORCA_WORKTREE_ID).toBe('wt-2')
// Local hook server coords must NOT cross the wire — the relay is the source of truth.
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag
}
}
})
})
})
it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => {
const confirmForegroundProcess = vi.fn(async () => 'codex')
registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never)
setPtyOwnership('remote-pty', 'ssh-1')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex')
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty')
deletePtyOwnership('remote-pty')
})
it('routes runtime exact liveness without enumerating provider sessions', () => {
const provider = getLocalPtyProvider()
const hasPty = vi.spyOn(provider, 'hasPty').mockImplementation((id) => id === 'live-pty')
const listProcesses = vi.spyOn(provider, 'listProcesses')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
hasPty: (ptyId: string) => boolean | null
}
expect(controller.hasPty('live-pty')).toBe(true)
expect(controller.hasPty('missing-pty')).toBe(false)
expect(hasPty).toHaveBeenCalledTimes(2)
expect(listProcesses).not.toHaveBeenCalled()
})
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
registerSshPtyProvider('ssh-1', {} as never)
setPtyOwnership('unsupported-pty', 'ssh-1')
setPtyOwnership('missing-pty', 'missing-connection')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull()
await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull()
deletePtyOwnership('unsupported-pty')
deletePtyOwnership('missing-pty')
})
it('rethrows non-not-found local provider shutdown failures', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('daemon unavailable')),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:kill')!(null, { id: 'local-pty' })).rejects.toThrow(
'daemon unavailable'
)
})
it('rejects runtime terminal IDs before unowned local provider routing', async () => {
const shutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:kill')!(null, { id: 'remote:env-1@@terminal-1' })
).rejects.toThrow('Invalid PTY provider id')
expect(shutdown).not.toHaveBeenCalled()
})
it('synthesizes runtime exit after ordinary daemon-backed pty kill', async () => {
const shutdown = vi.fn(async () => undefined)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:kill')!(null, { id: 'local-pty', keepHistory: true })
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1, undefined)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: 'local-pty',
code: -1
})
})
it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => {
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const shutdown = vi.fn(async (id: string) => {
for (const listener of exitListeners) {
listener({ id, code: 0 })
}
})
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
[['pty:exit', { id: 'local-pty', code: 0 }]]
)
})
it('ignores a late provider exit after synthesizing kill exit', async () => {
const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(async () => undefined),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
exitListeners.add(listener)
return () => exitListeners.delete(listener)
}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
for (const listener of exitListeners) {
listener({ id: 'local-pty', code: 0 })
}
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1, undefined)
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
[['pty:exit', { id: 'local-pty', code: -1 }]]
)
})
it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => {
const barrier = makeDeferred()
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyStartup: () => barrier.promise
}
)
const pendingSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
}) as Promise<{ id: string }>
await Promise.resolve()
expect(spawnMock).not.toHaveBeenCalled()
const daemonSpawn = installDaemonTestProvider()
barrier.resolve()
const result = await pendingSpawn
expect(daemonSpawn).toHaveBeenCalledTimes(1)
expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId)
expect(spawnMock).not.toHaveBeenCalled()
})
// Why: cold-start teardown must select the daemon after startup, else fallback shutdown orphans the restored daemon PTY (#7742).
it('waits for the desktop startup barrier before renderer local kills resolve the provider', async () => {
const barrier = makeDeferred()
const awaitLocalPtyStartup = vi.fn(() => new Promise<void>(() => {}))
const awaitLocalPtyProviderStartup = vi.fn(() => barrier.promise)
const fallbackShutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyStartup,
awaitLocalPtyProviderStartup
}
)
const daemonSessionId = 'wt-1@@11111111-1111-1111-1111-111111111111'
const pendingKill = handlers.get('pty:kill')!(null, { id: daemonSessionId }) as Promise<void>
await Promise.resolve()
expect(awaitLocalPtyStartup).not.toHaveBeenCalled()
expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1)
expect(fallbackShutdown).not.toHaveBeenCalled()
const daemon = installObservableDaemonTestProvider()
barrier.resolve()
await pendingKill
expect(daemon.spawn).not.toHaveBeenCalled()
expect(daemon.shutdown).toHaveBeenCalledWith(
daemonSessionId,
expect.objectContaining({ immediate: true })
)
expect(fallbackShutdown).not.toHaveBeenCalled()
})
it('waits for the desktop startup barrier before runtime local kills resolve the provider', async () => {
const barrier = makeDeferred()
const awaitLocalPtyProviderStartup = vi.fn(() => barrier.promise)
const fallbackShutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() }
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyProviderStartup
}
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(controller.kill('daemon-session')).toBe(true)
await Promise.resolve()
expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1)
expect(fallbackShutdown).not.toHaveBeenCalled()
const daemon = installObservableDaemonTestProvider()
barrier.resolve()
await vi.waitFor(() => expect(daemon.shutdown).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalledTimes(1))
expect(daemon.shutdown).toHaveBeenCalledWith('daemon-session', { immediate: false })
expect(fallbackShutdown).not.toHaveBeenCalled()
})
it('waits for the desktop startup barrier before runtime exact stops resolve the provider', async () => {
const barrier = makeDeferred()
const awaitLocalPtyProviderStartup = vi.fn(() => barrier.promise)
const fallbackShutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() }
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyProviderStartup
}
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string) => Promise<boolean>
}
const pendingStop = controller.stopAndWait('daemon-session')
await Promise.resolve()
expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1)
expect(fallbackShutdown).not.toHaveBeenCalled()
const daemon = installObservableDaemonTestProvider()
barrier.resolve()
await expect(pendingStop).resolves.toBe(true)
expect(daemon.shutdown).toHaveBeenCalledWith('daemon-session', {
immediate: true,
keepHistory: false
})
expect(fallbackShutdown).not.toHaveBeenCalled()
})
it('rebinds local data and exit listeners after a late daemon provider install', async () => {
vi.useFakeTimers()
const barrier = makeDeferred()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 13),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyStartup: () => barrier.promise
}
)
const pendingSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
}) as Promise<{ id: string }>
await Promise.resolve()
const daemon = installObservableDaemonTestProvider()
rebindLocalProviderListeners()
barrier.resolve()
const result = await pendingSpawn
daemon.emitData(result.id, 'daemon output')
vi.advanceTimersByTime(2)
daemon.emitExit(result.id, 0)
expect(daemon.spawn).toHaveBeenCalledTimes(1)
expect(runtime.onPtyData).toHaveBeenCalledWith(
result.id,
'daemon output',
expect.any(Number),
'daemon output'.length,
undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: result.id,
data: 'daemon output',
seq: 13,
rawLength: 'daemon output'.length
})
expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: result.id,
code: 0
})
} finally {
vi.useRealTimers()
}
})
// Why: the cap/flag must never fire in the common case (renderer keeps up), so small output carries no droppedBacklog.
it('does not flag droppedBacklog for ordinary small output under the cap', async () => {
vi.useFakeTimers()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 12),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'),
registerPreAllocatedHandleForPty: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ awaitLocalPtyStartup: () => Promise.resolve() }
)
const pendingSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'small-output-session'
}) as Promise<{ id: string }>
await Promise.resolve()
const daemon = installObservableDaemonTestProvider()
rebindLocalProviderListeners()
const result = await pendingSpawn
daemon.emitData(result.id, 'small output')
await vi.advanceTimersByTimeAsync(50)
const dataSends = mainWindow.webContents.send.mock.calls.filter(
(call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id
)
expect(dataSends.length).toBeGreaterThan(0)
for (const call of dataSends) {
expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined()
}
} finally {
vi.useRealTimers()
}
})
it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => {
const barrier = makeDeferred()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{
awaitLocalPtyStartup: () => barrier.promise
}
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn: (args: { cols: number; rows: number; env?: Record<string, string> }) => Promise<{
id: string
}>
}
const pendingSpawn = controller.spawn({ cols: 80, rows: 24, env: {} })
await Promise.resolve()
expect(spawnMock).not.toHaveBeenCalled()
const daemonSpawn = installDaemonTestProvider()
barrier.resolve()
const result = await pendingSpawn
expect(daemonSpawn).toHaveBeenCalledTimes(1)
expect(result.id).toBe(daemonSpawn.mock.calls[0]?.[0].sessionId)
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not wait on the desktop startup barrier for SSH spawns or kills', async () => {
const barrier = makeDeferred()
const awaitLocalPtyStartup = vi.fn(() => barrier.promise)
const sshSpawn = vi.fn(async () => ({ id: 'remote-pty' }))
const sshShutdown = vi.fn()
registerSshPtyProvider('ssh-1', {
spawn: sshSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
undefined,
{ awaitLocalPtyStartup }
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: {}
})
).resolves.toEqual(expect.objectContaining({ id: 'remote-pty' }))
await handlers.get('pty:kill')!(null, { id: 'remote-pty' })
expect(awaitLocalPtyStartup).not.toHaveBeenCalled()
expect(sshSpawn).toHaveBeenCalledTimes(1)
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: false
})
})
it('lists sessions from both local and SSH providers', async () => {
registerPtyHandlers(mainWindow as never)
const sshListProcesses = vi.fn(async () => [
{ id: 'remote-pty', cwd: '/remote', title: 'ssh-shell' }
])
const sshShutdown = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sshListProcesses).toHaveBeenCalled()
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ cwd: '/remote', id: 'remote-pty', title: 'ssh-shell' })
])
)
await handlers.get('pty:kill')!(null, { id: 'remote-pty' })
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: false
})
})
it('starts local and SSH session inventories concurrently', async () => {
let resolveLocal!: (sessions: { id: string; cwd: string; title: string }[]) => void
const localSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveLocal = resolve
})
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockReturnValue(localSessions)
registerPtyHandlers(mainWindow as never)
let resolveSsh!: (sessions: { id: string; cwd: string; title: string }[]) => void
const sshSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
resolveSsh = resolve
})
const sshListProcesses = vi.fn(() => sshSessions)
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: sshListProcesses,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const pendingInventory = handlers.get('pty:listSessions')!(null, undefined)
expect(sshListProcesses).toHaveBeenCalledTimes(1)
resolveLocal([])
resolveSsh([])
await pendingInventory
})
it('reports authoritative snapshot capability with the owning provider context', () => {
const capabilityProvider = {
authoritativeIds: new Set(['current-pty']),
canProvideAuthoritativeBufferSnapshot(id: string) {
return this.authoritativeIds.has(id)
}
}
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider(capabilityProvider as never)
const listener = onMock.mock.calls.find(
([channel]) => channel === 'pty:getAuthoritativeBufferSnapshotCapabilitiesSync'
)?.[1] as ((event: { returnValue?: unknown }, args: { ids: unknown[] }) => void) | undefined
const event: { returnValue?: unknown } = {}
listener?.(event, { ids: ['current-pty', 'legacy-pty', 'current-pty', 42] })
expect(event.returnValue).toEqual([
{ id: 'current-pty', authoritative: true },
{ id: 'legacy-pty', authoritative: false }
])
})
it('checks single-PTY liveness without listing every session', async () => {
const hasPty = vi.fn((id: string) => id === 'live-pty')
const listProcesses = vi.fn(async () => {
throw new Error('listProcesses should not be called')
})
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
hasPty,
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'live-pty' })).resolves.toBe(true)
await expect(handlers.get('pty:hasPty')!(null, { id: 'dead-pty' })).resolves.toBe(false)
expect(hasPty).toHaveBeenCalledWith('live-pty')
expect(hasPty).toHaveBeenCalledWith('dead-pty')
expect(listProcesses).not.toHaveBeenCalled()
})
it('treats unsupported or failed single-PTY liveness as unknown', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null)
const hasPty = vi.fn(() => {
throw new Error('provider unavailable')
})
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
hasPty,
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null)
})
it('lists duplicate SSH relay session ids as distinct app sessions', async () => {
registerPtyHandlers(mainWindow as never)
const shutdownA = vi.fn(async () => undefined)
const shutdownB = vi.fn(async () => undefined)
registerSshPtyProvider('ssh-a', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownA,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a', title: 'ssh-a' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerSshPtyProvider('ssh-b', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: shutdownB,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [
{ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b', title: 'ssh-b' }
]),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
cwd: string
title: string
}[]
expect(sessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'ssh:ssh-a@@pty-1', cwd: '/repo-a' }),
expect.objectContaining({ id: 'ssh:ssh-b@@pty-1', cwd: '/repo-b' })
])
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-a@@pty-1' })
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-b@@pty-1' })
expect(shutdownA).toHaveBeenCalledWith('ssh:ssh-a@@pty-1', {
immediate: true,
keepHistory: false
})
expect(shutdownB).toHaveBeenCalledWith('ssh:ssh-b@@pty-1', {
immediate: true,
keepHistory: false
})
})
it('kills app-scoped SSH PTY ids through the parsed provider when ownership is not rebuilt', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sshShutdown = vi.fn(async () => undefined)
const store = { markSshRemotePtyLease: vi.fn() }
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: sshShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })
expect(sshShutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', {
immediate: true,
keepHistory: false
})
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('tombstones app-scoped SSH PTY ids instead of falling back local when ownership and provider are absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: localShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = { markSshRemotePtyLease: vi.fn() }
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })
expect(localShutdown).not.toHaveBeenCalled()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
})
it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => {
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
const provider = {
spawn: vi.fn(async () => ({ id: 'remote-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(async () => undefined),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
undefined,
undefined,
store as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: {}
})
unregisterSshPtyProvider('ssh-1')
const listenerFor = (channel: string): ((event: unknown, args: unknown) => void) => {
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
if (!call) {
throw new Error(`missing ${channel} listener`)
}
return call[1] as (event: unknown, args: unknown) => void
}
expect(() =>
listenerFor('pty:write')(mainWindowIpcEvent, { id: 'remote-pty', data: 'x' })
).not.toThrow()
expect(() =>
listenerFor('pty:resize')(null, { id: 'remote-pty', cols: 100, rows: 30 })
).not.toThrow()
expect(() => listenerFor('pty:ackColdRestore')(null, { id: 'remote-pty' })).not.toThrow()
expect(() =>
listenerFor('pty:signal')(null, { id: 'remote-pty', signal: 'SIGINT' })
).not.toThrow()
await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).resolves.toBeUndefined()
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated')
})
it('returns idle process inspection results for detached SSH PTYs without a provider', async () => {
const provider = {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
confirmForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
}
registerSshPtyProvider('ssh-1', provider as never)
registerPtyHandlers(mainWindow as never)
setPtyOwnership('remote-pty', 'ssh-1')
unregisterSshPtyProvider('ssh-1')
await expect(handlers.get('pty:hasChildProcesses')!(null, { id: 'remote-pty' })).resolves.toBe(
false
)
await expect(
handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
await expect(
handlers.get('pty:confirmForegroundProcess')!(null, { id: 'remote-pty' })
).resolves.toBeNull()
expect(provider.hasChildProcesses).not.toHaveBeenCalled()
expect(provider.getForegroundProcess).not.toHaveBeenCalled()
expect(provider.confirmForegroundProcess).not.toHaveBeenCalled()
})
it('preserves unavailable process inspection results from the provider', async () => {
const inspectProcess = vi.fn(async () => ({
foregroundProcess: null,
hasChildProcesses: true,
unavailable: true as const
}))
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider({ inspectProcess } as never)
await expect(
handlers.get('pty:inspectProcess')!(null, { id: 'legacy-daemon-pty' })
).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: true,
unavailable: true
})
})
// Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync).
describe('pty:getSize reports applied size, not requested size', () => {
function setupProviderWithAppliedSize(args: {
applied: { cols: number; rows: number } | null
resize?: (cols: number, rows: number) => void
getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null>
}): ReturnType<typeof vi.fn> {
const write = vi.fn()
setLocalPtyProvider({
spawn: vi.fn(async (opts: { sessionId?: string }) => ({
id: opts.sessionId ?? 'daemon-pty'
})),
write,
resize: vi.fn(args.resize ?? (() => {})),
getAppliedSize: vi.fn(args.getAppliedSize ?? (async () => args.applied)),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
return write
}
const resizeListener = (): ((event: unknown, args: unknown) => void) => {
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:resize')
if (!call) {
throw new Error('missing pty:resize listener')
}
return call[1] as (event: unknown, args: unknown) => void
}
it('returns the applied (wide) size after a dropped narrow resize', async () => {
// The daemon keeps the PTY at its wide spawn size; the narrow resize is silently dropped (fire-and-forget).
setupProviderWithAppliedSize({ applied: { cols: 200, rows: 50 } })
handlers.clear()
registerPtyHandlers(mainWindow as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 200, rows: 50, env: {} })
const id = (spawn as { id: string }).id
// Renderer forwards a corrective narrow resize; it is dropped daemon-side.
resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 })
// getSize must surface the applied wide size so the renderer detects drift and re-asserts — NOT requested 80.
const reported = await handlers.get('pty:getSize')!(null, { id })
expect(reported).toEqual({ cols: 200, rows: 50 })
})
it('falls back to the requested size when the provider cannot report applied size', async () => {
// No getAppliedSize (e.g. SSH relay): requested-size cache is the only signal, so getSize returns it.
setupProviderWithAppliedSize({ applied: null, getAppliedSize: undefined })
setLocalPtyProvider({
spawn: vi.fn(async (opts: { sessionId?: string }) => ({
id: opts.sessionId ?? 'daemon-pty'
})),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 200, rows: 50, env: {} })
const id = (spawn as { id: string }).id
resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 })
const reported = await handlers.get('pty:getSize')!(null, { id })
expect(reported).toEqual({ cols: 80, rows: 24 })
})
it('preserves provider-owned null so the renderer re-forwards an unverified size', async () => {
setupProviderWithAppliedSize({ applied: null, getAppliedSize: async () => null })
handlers.clear()
registerPtyHandlers(mainWindow as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} })
const id = (spawn as { id: string }).id
resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 })
const reported = await handlers.get('pty:getSize')!(null, { id })
expect(reported).toBeNull()
})
it('falls back to the requested size when getAppliedSize throws', async () => {
// A dead daemon/relay must never throw across the IPC boundary or block.
setupProviderWithAppliedSize({
applied: null,
getAppliedSize: async () => {
throw new Error('daemon unreachable')
}
})
handlers.clear()
registerPtyHandlers(mainWindow as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} })
const id = (spawn as { id: string }).id
const reported = await handlers.get('pty:getSize')!(null, { id })
expect(reported).toEqual({ cols: 100, rows: 30 })
})
it('fans out accepted desktop resizes to the runtime after provider resize', async () => {
const resize = vi.fn()
setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 })
expect(resize).toHaveBeenCalledWith(id, 120, 30)
expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30)
expect(resize.mock.invocationCallOrder[0]).toBeLessThan(
runtime.onExternalPtyResize.mock.invocationCallOrder[0]!
)
})
it('does not fan out rejected desktop resizes to the runtime', async () => {
setupProviderWithAppliedSize({
applied: { cols: 80, rows: 24 },
resize: () => {
throw new Error('resize rejected')
}
})
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 })
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
})
it('suppresses the host fit cascade while a remote viewer drives the width', async () => {
const resizeSpy = vi.fn()
setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'idle' })),
// The fix: a PTY with a remote viewer reports true even though driver state stays idle/desktop.
isRemoteDesktopResizeDriven: vi.fn(() => true),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
recordRemoteDesktopHostReclaimTarget: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
resizeSpy.mockClear()
// Host's own safeFit tries to widen the viewed PTY back to its window.
resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 })
// It must not reach the PTY while the viewer owns the width.
expect(resizeSpy).not.toHaveBeenCalled()
expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48)
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
})
it('lets trusted host activity reclaim remote viewport ownership', () => {
const claimRemoteDesktopHost = vi.fn().mockResolvedValue(true)
const runtime = {
setPtyController: vi.fn(),
claimRemoteDesktopHost
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport')
const claimListener = call?.[1] as
| ((event: unknown, args: { id: string; cols: number; rows: number }) => void)
| undefined
expect(claimListener).toBeTypeOf('function')
claimListener?.(mainWindowIpcEvent, { id: 'pty-1', cols: 125, rows: 48 })
expect(claimRemoteDesktopHost).toHaveBeenCalledWith('pty-1', 125, 48)
})
it('does not forward host input when viewport reclaim fails', async () => {
const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'idle' })),
claimRemoteDesktopHost: vi.fn().mockResolvedValue(false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
const claim = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport')
const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write')
claim?.[1](mainWindowIpcEvent, { id, cols: 125, rows: 48 })
writeEvent?.[1](mainWindowIpcEvent, { id, data: 'x' })
await Promise.resolve()
expect(write).not.toHaveBeenCalled()
})
it('does not populate the remote reclaim cache when only a phone drives', async () => {
const resizeSpy = vi.fn()
setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'mobile', clientId: 'phone-A' })),
isRemoteDesktopResizeDriven: vi.fn(() => false),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
recordRemoteDesktopHostReclaimTarget: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
resizeSpy.mockClear()
resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 })
expect(resizeSpy).not.toHaveBeenCalled()
expect(runtime.recordRemoteDesktopHostReclaimTarget).not.toHaveBeenCalled()
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
})
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
registerSshPtyProvider('ssh-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: { EXISTING: '1' }
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
EXISTING: '1',
ORCA_TERMINAL_HANDLE: 'term_remote'
})
})
)
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
'remote-pty',
'term_remote'
)
})
it('refreshes captured native Agent Teams env for renderer PTY spawns', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams'),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(async () => ({
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
PATH: `/tmp/fresh-agent-teams${delimiter}/usr/bin`,
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1',
TMUX_PANE: '%1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token'
}
})),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --teammate-mode auto --resume claude-session',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1',
env: {
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
CLAUDE_PROFILE: 'captured',
PATH: `/tmp/stale-agent-teams${delimiter}/usr/bin`,
TMUX: '/tmp/orca-claude-agent-teams/team-stale,0,1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
launchConfig: {
agentCommand: 'claude --teammate-mode auto',
agentArgs: '',
agentEnv: {
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token'
}
},
launchAgent: 'claude'
})) as { launchConfig?: { agentEnv: Record<string, string> } }
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith({
handle: 'term_agent_teams',
baseEnv: expect.objectContaining({
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale'
})
})
expect(spawnOptions.env).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_TERMINAL_HANDLE: 'term_agent_teams',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token',
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1',
TMUX_PANE: '%1'
})
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/fresh-agent-teams')
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(result.launchConfig?.agentEnv).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token',
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1'
})
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
expect.any(String),
'term_agent_teams'
)
})
it('threads the validated pane identity into registerPty for a renderer PTY spawn (#7587)', async () => {
const leafId = '88888888-8888-4888-8888-888888888888'
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_seam'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1'
})
// Why: #7587 — the runtime can only back a stalled mobile create if the spawn threads {tabId, leafId}.
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-1',
null,
{ tabId: 'tab-1', leafId, incarnationId: expect.any(String) },
false
)
})
it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => {
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_seam'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
tabId: 'tab-1',
leafId: 'pane:1',
worktreeId: 'wt-1'
})
// Why: legacy numeric pane ids (`pane:N`) aren't leaf ids, so the seam passes a clean `undefined` (no fabricated binding).
expect(runtime.registerPty).toHaveBeenCalledWith(
expect.any(String),
'wt-1',
null,
undefined,
false
)
})
it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams'),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(async () => ({
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token'
}
})),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --resume claude-session',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1',
env: {
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
launchConfig: {
agentCommand: 'claude',
agentArgs: '--teammate-mode auto',
agentEnv: {}
},
launchAgent: 'claude'
})
expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith({
handle: 'term_agent_teams',
baseEnv: expect.any(Object)
})
})
it('does not echo launch config for provider reattach results', async () => {
const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true }))
registerSshPtyProvider('ssh-reattach-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-1',
launchConfig: {
agentCommand: 'codex --model gpt-5',
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
})) as { id: string; isReattach?: boolean; launchConfig?: unknown }
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
preAllocatedHandle?: string
}): Promise<{ id: string }>
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_wrong'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
expect(controller).not.toBeNull()
const spawnController = controller as unknown as RuntimeSpawnController
const spawned = await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
preAllocatedHandle: 'term_expected'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_expected')
expect(runtime.preAllocateHandleForPty).not.toHaveBeenCalled()
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
expect.any(String),
'term_expected'
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
id: spawned.id
})
})
it('does not update cached PTY size when runtime controller resize fails', async () => {
type RuntimeResizeController = {
spawn(args: { cols: number; rows: number }): Promise<{ id: string }>
resize(ptyId: string, cols: number, rows: number): boolean
getSize(ptyId: string): { cols: number; rows: number } | null
}
let controller: RuntimeResizeController | null = null
const proc = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(() => {
throw new Error('resize failed')
}),
kill: vi.fn(),
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never, runtime as never)
const resizeController = controller as unknown as RuntimeResizeController
const spawned = await resizeController.spawn({ cols: 80, rows: 24 })
expect(resizeController.resize(spawned.id, 120, 30)).toBe(false)
expect(resizeController.getSize(spawned.id)).toEqual({ cols: 80, rows: 24 })
})
it('persists runtime-owned headless session bindings when explicitly requested', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId,
env: { ORCA_PANE_KEY: makePaneKey('tab-headless', leafId) },
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId,
ptyId: expect.any(String),
incarnationId: expect.any(String)
})
})
it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => {
const persistPtyBinding = vi.fn()
const onPtySpawnCommitted = vi.fn()
const runtime = new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
terminalMainSideEffectAuthority: true
}),
persistPtyBinding
} as never)
const provider = createAgentClaimProvider({
spawn: vi.fn(async () => {
runtime.onPtySpawned('pty-early-exit', 'incarnation-early-exit')
runtime.onPtyExit('pty-early-exit', 0, 'incarnation-early-exit')
return {
id: 'pty-early-exit',
incarnationId: 'incarnation-early-exit',
providerSequence: { value: 17, generation: 'reset' as const },
wslDistro: 'Ubuntu'
}
}),
authoritativeOwnerListings: false
})
setLocalPtyProvider(provider as never)
registerPtyHandlers(mainWindow as never, runtime, undefined, undefined, undefined, {
persistPtyBinding
} as never)
const controller = (
runtime as unknown as {
ptyController: {
spawn(args: Record<string, unknown>): Promise<unknown>
}
}
).ptyController
const tabId = '11111111-1111-4111-8111-111111111111'
const leafId = '22222222-2222-4222-8222-222222222222'
await expect(
controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo::/tmp/worktree',
tabId,
leafId,
preAllocatedHandle: 'term_early_exit',
persistHostSessionBinding: true,
onPtySpawnCommitted
})
).rejects.toThrow('agent_session_exited_during_start')
expect(onPtySpawnCommitted).toHaveBeenCalledOnce()
expect(persistPtyBinding).not.toHaveBeenCalled()
const internals = runtime as unknown as {
handleByPtyId: Map<string, string>
providerSequenceInitializedPtys: Set<string>
ptyOutputSequenceById: Map<string, number>
ptysById: Map<string, { connected: boolean }>
wslDistroByPtyId: Map<string, string>
earlyExitedPtyIncarnations: Map<string, string | null>
}
expect(internals.handleByPtyId.has('pty-early-exit')).toBe(false)
expect(internals.providerSequenceInitializedPtys.has('pty-early-exit')).toBe(false)
expect(internals.ptyOutputSequenceById.has('pty-early-exit')).toBe(false)
expect(internals.ptysById.get('pty-early-exit')?.connected).not.toBe(true)
expect(internals.wslDistroByPtyId.has('pty-early-exit')).toBe(false)
expect(internals.earlyExitedPtyIncarnations.has('pty-early-exit')).toBe(false)
clearProviderPtyState('pty-early-exit')
})
it('does not retain a claimed owner when its PTY exits before controller admission', async () => {
const runtime = new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
terminalMainSideEffectAuthority: true
})
} as never)
const sessions: {
id: string
incarnationId: string
cwd: string
title: string
}[] = []
let attempt = 0
const physicalSpawn = vi.fn(async () => {
attempt += 1
const incarnationId = attempt === 1 ? 'incarnation-exited' : 'incarnation-live'
if (attempt === 1) {
runtime.onPtySpawned('pty-claimed-admission', incarnationId)
runtime.onPtyExit('pty-claimed-admission', 0, incarnationId)
} else {
sessions.push({
id: 'pty-claimed-admission',
incarnationId,
cwd: '/tmp/worktree',
title: 'Codex'
})
}
return { id: 'pty-claimed-admission', incarnationId }
})
const provider = createAgentClaimProvider({
sessions,
spawn: physicalSpawn,
authoritativeOwnerListings: false
})
Object.assign(provider, { routesFreshSpawnsToLocalProvider: true })
setLocalPtyProvider(provider as never)
registerPtyHandlers(mainWindow as never, runtime)
const controller = (
runtime as unknown as {
ptyController: { spawn(args: Record<string, unknown>): Promise<unknown> }
}
).ptyController
const request = {
cols: 80,
rows: 24,
cwd: '/tmp/worktree',
agentSessionEnsure: {
claim: recoveredAgentClaim,
surface: recoveredAgentSurface
}
}
await expect(controller.spawn(request)).rejects.toThrow('agent_session_exited_during_start')
expect(
isCurrentPtyExit({
id: 'pty-claimed-admission',
incarnationId: 'unrelated-incarnation'
})
).toBe(true)
await expect(controller.spawn(request)).resolves.toMatchObject({
id: 'pty-claimed-admission',
agentSessionEnsure: { disposition: 'created' }
})
expect(physicalSpawn).toHaveBeenCalledTimes(2)
clearProviderPtyState('pty-claimed-admission')
})
it('reuses runtime materialization when renderer focuses the same pane during spawn', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let resolveSpawn!: (result: { id: string }) => void
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '22222222-2222-4222-8222-222222222222'
const paneKey = makePaneKey('tab-race', leafId)
const runtimeSpawn = spawnController.spawn({
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
})
await Promise.resolve()
// Why: SSH can strip ORCA_PANE_KEY before spawn; tab/leaf metadata must still dedupe against runtime materialization.
const rendererSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
expect(providerSpawn).toHaveBeenCalledTimes(1)
resolveSpawn({ id: 'pty-shared' })
await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([
{ id: 'pty-shared' },
{ id: 'pty-shared' }
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-shared',
startupCwd: '/tmp'
})
})
it('reuses renderer spawn when runtime materialization starts for the same pane', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let resolveSpawn!: (result: { id: string }) => void
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const leafId = '33333333-3333-4333-8333-333333333333'
const paneKey = makePaneKey('tab-race', leafId)
const rendererSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
const spawnController = controller as unknown as RuntimeSpawnController
const runtimeSpawn = spawnController.spawn({
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
})
await Promise.resolve()
expect(providerSpawn).toHaveBeenCalledTimes(1)
resolveSpawn({ id: 'pty-renderer' })
await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([
{ id: 'pty-renderer' },
{ id: 'pty-renderer' }
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-renderer',
startupCwd: '/tmp'
})
})
it('settles the pane reservation when a post-spawn step throws so later spawns do not hang', async () => {
// Why: reservation-leak regression — a post-spawn throw after provider.spawn resolves must reject/clear the reservation, else later spawns for the same pane key hang forever.
registerPtyHandlers(mainWindow as never)
const leafId = '44444444-4444-4444-8444-444444444444'
const spawnArgs = { cols: 80, rows: 24, tabId: 'tab-reservation', leafId }
registerPtyMock.mockImplementationOnce(() => {
throw new Error('boom: post-spawn registration failed')
})
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow('boom')
// A second spawn for the same pane must run a fresh spawn rather than await the leaked (never-settled) reservation promise.
let hangTimer: ReturnType<typeof setTimeout> | undefined
const second = handlers.get('pty:spawn')!(null, spawnArgs) as Promise<{ id: string }>
const result = await Promise.race([
second,
new Promise<never>((_, reject) => {
hangTimer = setTimeout(
() => reject(new Error('second spawn hung: pane reservation leaked')),
1000
)
})
]).finally(() => clearTimeout(hangTimer))
expect(result.id).toEqual(expect.any(String))
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('settles the runtime-owned pane reservation when a post-spawn step throws so later spawns do not hang', async () => {
// Why: like the renderer-path regression, the runtime spawn path must clear its own reservation when a post-spawn step throws, else the next materialization hangs forever.
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
let spawnCount = 0
const providerSpawn = vi.fn(async () => ({ id: `pty-${++spawnCount}` }))
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn().mockImplementationOnce(() => {
throw new Error('boom: runtime registration failed')
}),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '55555555-5555-4555-8555-555555555555'
const paneKey = makePaneKey('tab-runtime-reservation', leafId)
const spawnArgs = {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
tabId: 'tab-runtime-reservation',
leafId,
env: { ORCA_PANE_KEY: paneKey },
persistHostSessionBinding: true
}
await expect(spawnController.spawn(spawnArgs)).rejects.toThrow('boom')
// The reservation must be gone, so a second materialization runs a fresh provider.spawn instead of awaiting the leaked promise.
let hangTimer: ReturnType<typeof setTimeout> | undefined
const second = spawnController.spawn(spawnArgs)
const result = await Promise.race([
second,
new Promise<never>((_, reject) => {
hangTimer = setTimeout(
() => reject(new Error('second runtime spawn hung: pane reservation leaked')),
1000
)
})
]).finally(() => clearTimeout(hangTimer))
expect(result.id).toEqual(expect.any(String))
expect(providerSpawn).toHaveBeenCalledTimes(2)
})
it('records SSH leases for runtime-owned headless session bindings', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteSpawn = vi.fn(async () => ({ id: 'ssh:ssh-1@@relay-pty' }))
registerSshPtyProvider('ssh-1', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-1',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-1@@relay-pty',
persistHostSessionBinding: true
})
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-1',
ptyId: 'relay-pty',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
state: 'attached'
})
)
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty'
},
'ssh:ssh-1'
)
expect(store.persistPtyBinding.mock.invocationCallOrder[0]!).toBeLessThan(
store.upsertSshRemotePtyLease.mock.invocationCallOrder[0]!
)
unregisterSshPtyProvider('ssh-1')
})
it('rejects runtime-owned binding persistence without complete stable identity', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const store = {
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const validLeafId = '11111111-1111-4111-8111-111111111111'
const baseArgs = {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-headless',
leafId: validLeafId,
persistHostSessionBinding: true
}
for (const args of [
{ ...baseArgs, worktreeId: undefined },
{ ...baseArgs, tabId: undefined },
{ ...baseArgs, leafId: undefined },
{ ...baseArgs, leafId: 'legacy-leaf' }
]) {
await expect(spawnController.spawn(args)).rejects.toThrow(
'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId'
)
}
expect(spawnMock).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
})
it('refreshes SSH leases after successful runtime-owned reattach binding', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string; isReattach?: boolean }>
}
registerSshPtyProvider('ssh-reattach-ok', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-ok@@relay-pty', isReattach: true })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-ok',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-ok@@relay-pty',
persistHostSessionBinding: true
})
expect(store.persistPtyBinding).toHaveBeenCalledWith(
{
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty'
},
'ssh:ssh-reattach-ok'
)
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-reattach-ok',
ptyId: 'relay-pty',
state: 'attached',
lastAttachedAt: expect.any(Number)
})
)
} finally {
unregisterSshPtyProvider('ssh-reattach-ok')
}
})
it('strips runtime-owned SSH pane env when remote agent hooks are disabled', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
env?: Record<string, string>
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const savedRemoteHooks = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0'
const remoteSpawn = vi.fn(
async (_opts: { env?: Record<string, string>; envToDelete?: string[] }) => ({
id: 'ssh:ssh-runtime-env@@relay-pty'
})
)
registerSshPtyProvider('ssh-runtime-env', {
spawn: remoteSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
(() => ({
agentStatusHooksEnabled: false,
codexSystemDefaultRealHomeEnabled: true
})) as never,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await spawnController.spawn({
cols: 80,
rows: 24,
env: {
FOO: 'bar',
ORCA_PANE_KEY: makePaneKey('tab-remote', leafId),
ORCA_TAB_ID: 'tab-remote',
ORCA_WORKTREE_ID: 'wt-remote'
},
connectionId: 'ssh-runtime-env',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
persistHostSessionBinding: true
})
const spawnOptions = remoteSpawn.mock.calls[0]?.[0]
const env = spawnOptions.env
expect(env).toMatchObject({ FOO: 'bar' })
expect(env?.ORCA_PANE_KEY).toBeUndefined()
expect(env?.ORCA_TAB_ID).toBeUndefined()
expect(env?.ORCA_WORKTREE_ID).toBeUndefined()
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'ssh-runtime-env',
ptyId: 'relay-pty',
leafId,
state: 'attached'
})
)
} finally {
if (savedRemoteHooks === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
} else {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = savedRemoteHooks
}
unregisterSshPtyProvider('ssh-runtime-env')
}
})
it('does not leave SSH leases when runtime-owned binding persistence fails after reattach', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const remoteShutdown = vi.fn()
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-reattach-fail', {
spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-fail@@relay-pty', isReattach: true })),
write: remoteWrite,
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: 'ssh:ssh-reattach-fail@@relay-pty',
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(remoteShutdown).not.toHaveBeenCalled()
getPtyWriteListener()(mainWindowIpcEvent, {
id: 'ssh:ssh-reattach-fail@@relay-pty',
data: 'echo should-not-route'
})
expect(remoteWrite).not.toHaveBeenCalled()
unregisterSshPtyProvider('ssh-reattach-fail')
})
it('marks runtime-owned SSH reattach as expired and clears stale local ownership', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-expired-runtime@@relay-pty'
const remoteWrite = vi.fn()
registerSshPtyProvider('ssh-expired-runtime', {
spawn: vi.fn(async () => {
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: relay-pty`)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, 'ssh-expired-runtime')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-expired-runtime',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-expired-runtime',
'relay-pty',
'expired'
)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo nope' })
expect(remoteWrite).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider('ssh-expired-runtime')
}
})
it('does not clear runtime-owned SSH reattach state on identity mismatch', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const connectionId = 'ssh-identity-runtime'
const appPtyId = `ssh:${connectionId}@@relay-pty`
const remoteWrite = vi.fn()
registerSshPtyProvider(connectionId, {
spawn: vi.fn(async () => {
throw new Error(
`${SSH_SESSION_EXPIRED_ERROR}: relay-pty ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
)
}),
write: remoteWrite,
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
setPtyOwnership(appPtyId, connectionId)
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = controller as unknown as RuntimeSpawnController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId,
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(SSH_SESSION_EXPIRED_ERROR)
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
connectionId,
'relay-pty',
'expired'
)
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).not.toHaveBeenCalledWith(appPtyId)
getPtyWriteListener()(mainWindowIpcEvent, { id: appPtyId, data: 'echo still-owned' })
expect(remoteWrite).toHaveBeenCalledWith(appPtyId, 'echo still-owned')
} finally {
deletePtyOwnership(appPtyId)
unregisterSshPtyProvider(connectionId)
}
})
it('cleans up fresh runtime-owned SSH spawns when binding persistence fails', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
connectionId?: string
tabId?: string
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
}): Promise<{ id: string }>
}
const appPtyId = 'ssh:ssh-fresh-fail@@relay-pty'
const incarnationId = 'incarnation-fresh-fail'
const runtime = new OrcaRuntimeService()
const remoteShutdown = vi.fn(async () => {
// Model the relay's exit callback winning before shutdown resolves.
runtime.onPtyExit(appPtyId, 0, incarnationId)
})
registerSshPtyProvider('ssh-fresh-fail', {
spawn: vi.fn(async () => ({ id: appPtyId, incarnationId })),
write: vi.fn(),
resize: vi.fn(),
shutdown: remoteShutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const store = {
upsertSshRemotePtyLease: vi.fn(),
persistPtyBinding: vi.fn(() => {
throw new Error('disk full')
}),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const spawnController = (runtime as unknown as { ptyController: RuntimeSpawnController })
.ptyController
const leafId = '11111111-1111-4111-8111-111111111111'
await expect(
spawnController.spawn({
cols: 80,
rows: 24,
connectionId: 'ssh-fresh-fail',
worktreeId: 'wt-remote',
tabId: 'tab-remote',
leafId,
sessionId: appPtyId,
persistHostSessionBinding: true
})
).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/)
expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true })
expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled()
expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled()
expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId)
expect(piClearPtyMock).toHaveBeenCalledWith(appPtyId)
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
} finally {
unregisterSshPtyProvider('ssh-fresh-fail')
}
})
it('maps runtime-owned spawn paneKeys for renderer serializer settlement', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
hasRendererSerializer?(ptyId: string): boolean
getRendererSerializerGeneration?(ptyId: string): number
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const paneKey = makePaneKey('tab-cli', '11111111-1111-4111-8111-111111111111')
const gen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
const spawnController = controller as unknown as RuntimeSpawnController
const result = await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
env: { ORCA_PANE_KEY: ` ${paneKey} ` }
})
const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen })
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false)
expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen })
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true)
expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1)
})
it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
getRendererSerializerGeneration?(ptyId: string): number
hasRendererSerializer?(ptyId: string): boolean
waitForRendererSerializer?(
ptyId: string,
afterGeneration: number,
timeoutMs?: number
): Promise<boolean>
}
const reusedPtyId = 'pty-reused'
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: reusedPtyId })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
preAllocateHandleForPty: vi.fn(() => null),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const paneKey = makePaneKey('tab-reused', '33333333-3333-4333-8333-333333333333')
const spawnController = controller as unknown as RuntimeSpawnController
const spawn = async (): Promise<void> => {
await spawnController.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-1',
env: { ORCA_PANE_KEY: paneKey }
})
}
const firstGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
await spawn()
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: firstGen })
const priorGeneration = spawnController.getRendererSerializerGeneration?.(reusedPtyId) ?? 0
const secondGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
await spawn()
const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000)
clearProviderPtyState(reusedPtyId)
clearProviderPtyState(reusedPtyId)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen })
await expect(ready).resolves.toBe(true)
expect(spawnController.hasRendererSerializer?.(reusedPtyId)).toBe(true)
})
it('tracks exact remote-runtime serializer readiness without a local spawn mapping', async () => {
type RuntimeSpawnController = {
hasRendererSerializer?(ptyId: string): boolean
getRendererSerializerGeneration?(ptyId: string): number
}
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
})
}
registerPtyHandlers(mainWindow as never, runtime as never)
const ptyId = 'remote:env-1@@terminal-1'
const spawnController = controller as unknown as RuntimeSpawnController
expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(false)
await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId: 'local-pty' })
expect(spawnController.hasRendererSerializer?.('local-pty')).toBe(false)
await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId })
expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(true)
expect(spawnController.getRendererSerializerGeneration?.(ptyId)).toBe(1)
})
it('clears pending pane serializer declarations when their renderer is destroyed', async () => {
registerPtyHandlers(mainWindow as never)
const paneKey = makePaneKey('tab-crash', '22222222-2222-4222-8222-222222222222')
const destroyedListeners: (() => void)[] = []
const sender = {
id: 42,
once: vi.fn((event: string, listener: () => void) => {
if (event === 'destroyed') {
destroyedListeners.push(listener)
}
})
}
await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey })
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(true)
expect(destroyedListeners).toHaveLength(1)
destroyedListeners[0]()
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false)
})
it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => {
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_trusted'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' }
})
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted')
expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String))
})
it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_wsl'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
expect(env.ORCA_CLI_COMMAND).toBe('orca-ide')
expect(env.WSLENV?.split(':')).toEqual(
expect.arrayContaining([
'ORCA_TERMINAL_HANDLE/u',
'ORCA_USER_DATA_PATH/p',
'ORCA_CLI_COMMAND/u',
'ORCA_AGENT_HOOK_PORT/u',
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
'ORCA_OMP_STATUS_EXTENSION/p',
'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
])
)
})
it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const runtime = {
setPtyController: vi.fn(),
preAllocateHandleForPty: vi.fn(() => 'term_wsl'),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
try {
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
env: {
ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(spawnCall[0]).toBe('wsl.exe')
expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data')
})
describe('Windows UTF-8 code page', () => {
let originalPlatform: string
let originalComspec: string | undefined
const savedWindowsResolutionEnv: Record<string, string | undefined> = {}
beforeEach(() => {
originalPlatform = process.platform
originalComspec = process.env.COMSPEC
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
process.env.USERPROFILE = 'C:\\Users\\test'
// Why: the spawn path resolves a bare PowerShell name to an absolute exe (PR #6537 / issue #5161); pin the probed install roots for deterministic resolution across host OS.
for (const key of ['SystemRoot', 'ProgramW6432', 'ProgramFiles', 'ProgramFiles(x86)']) {
savedWindowsResolutionEnv[key] = process.env[key]
}
process.env.SystemRoot = 'C:\\Windows'
process.env.ProgramW6432 = 'C:\\Program Files'
process.env.ProgramFiles = 'C:\\Program Files'
delete process.env['ProgramFiles(x86)']
// Why: .exe candidates must report isFile()/size (default mock omits them, collapsing every PowerShell candidate to cmd.exe); dirs must still report isDirectory().
statSyncMock.mockImplementation((target: string) => {
const isExe = /\.exe$/i.test(String(target))
return {
isDirectory: () => !isExe,
isFile: () => isExe,
size: isExe ? 1024 : 0,
mode: 0o755
}
})
existsSyncMock.mockReturnValue(true)
})
afterEach(() => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalComspec === undefined) {
delete process.env.COMSPEC
} else {
process.env.COMSPEC = originalComspec
}
for (const [key, value] of Object.entries(savedWindowsResolutionEnv)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete process.env.PYTHONUTF8
})
it('passes chcp 65001 to cmd.exe for UTF-8 console output', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\system32\\cmd.exe',
['/K', 'chcp 65001 > nul'],
expect.any(Object)
)
})
it('sets Console encoding for powershell.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('sets Console encoding for pwsh.exe', async () => {
process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('sets PYTHONUTF8=1 in the spawn environment on Windows', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('1')
})
it('does not override an existing PYTHONUTF8 value', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
process.env.PYTHONUTF8 = '0'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('0')
})
it('launches Git Bash from COMSPEC as an interactive login shell', async () => {
process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
expect.objectContaining({
env: expect.objectContaining({ CHERE_INVOKING: '1' })
})
)
})
it('uses terminalWindowsShell setting over COMSPEC when provided', async () => {
// Why: COMSPEC always points to cmd.exe on stock Windows, so without the setting the terminal would ignore the user's shell preference.
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
RESOLVED_WINDOWS_POWERSHELL,
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('uses the host shell when resolved project runtime overrides a stale WSL shell default', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
}) as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'windows-host',
hostPlatform: 'win32',
projectId: 'repo-1',
source: 'project-override',
cacheKey: 'repo-1:windows-host'
}
}
})
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\system32\\cmd.exe',
['/K', 'chcp 65001 > nul'],
expect.any(Object)
)
})
it('uses the selected project WSL distro when resolved runtime overrides the host shell default', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsWslDistro: 'Debian'
}) as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: 'C:\\Users\\test\\repo',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo-1',
distro: 'Ubuntu',
source: 'project-override',
cacheKey: 'repo-1:wsl:Ubuntu'
}
}
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[1]).toEqual(expect.arrayContaining(['-d', 'Ubuntu']))
})
it('blocks terminal spawn when project runtime requires repair', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe'
}) as never
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
projectRuntime: {
status: 'repair-required',
repair: {
projectId: 'repo-1',
reason: 'wsl-distro-missing',
requestedDistro: 'Ubuntu',
fallbackRuntime: null,
cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu'
}
}
})
).rejects.toThrow('Project runtime requires repair before terminal spawn')
expect(spawnMock).not.toHaveBeenCalled()
})
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
RESOLVED_WINDOWS_POWERSHELL,
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('spawns pwsh.exe when PowerShell 7 is selected and available', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
RESOLVED_PWSH7,
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
})
it('keeps PowerShell 7 selected when the pwsh availability probe is cold-false', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
RESOLVED_PWSH7,
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
expect(isPwshAvailableMock).not.toHaveBeenCalled()
})
it('keeps a pwsh.exe shellOverride when the pwsh availability probe is cold-false', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
expect(spawnMock).toHaveBeenCalledWith(
RESOLVED_PWSH7,
POWERSHELL_OSC133_ARGS,
expect.any(Object)
)
expect(isPwshAvailableMock).not.toHaveBeenCalled()
})
it('ignores the PowerShell implementation setting for cmd.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
undefined,
() =>
({
terminalWindowsShell: 'cmd.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'cmd.exe',
['/K', 'chcp 65001 > nul'],
expect.any(Object)
)
})
it('ignores the PowerShell implementation setting for wsl.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
registerPtyHandlers(
mainWindow as never,
undefined,
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
() =>
({
terminalWindowsShell: 'wsl.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
it('keeps shellOverride priority for one-off tabs', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
registerPtyHandlers(
mainWindow as never,
undefined,
() => 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
() =>
({
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
})
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
expect(spawnOptions.env.CODEX_HOME).toBeUndefined()
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
})
it('passes floating terminal cwds through to the spawned shell', async () => {
// Why: the floating sentinel has no worktree root; its cwd is validated against trusted-directory grants before reaching pty:spawn.
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp/floating-notes',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
expect(options.cwd).toBe('/tmp/floating-notes')
})
it('rejects a renderer spawn while destructive worktree removal holds the gate', async () => {
const removal = acquireWatcherRemovalGate('/repo/app')
await removal.ready
registerPtyHandlers(mainWindow as never)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'repo-1::/repo/app'
})
).rejects.toMatchObject({ code: 'terminal_removal_in_progress' })
expect(spawnMock).not.toHaveBeenCalled()
} finally {
removal.release()
}
})
it('rejects a sibling-worktree terminal cwd inside a worktree being removed', async () => {
const removal = acquireWatcherRemovalGate('/repo/app')
await removal.ready
registerPtyHandlers(mainWindow as never)
try {
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/repo/app/nested',
worktreeId: 'repo-1::/repo/sibling'
})
).rejects.toMatchObject({ code: 'terminal_removal_in_progress' })
expect(spawnMock).not.toHaveBeenCalled()
} finally {
removal.release()
}
const siblingRemoval = acquireWatcherRemovalGate('/repo/sibling')
await siblingRemoval.ready
siblingRemoval.release()
})
it('rejects a runtime sibling-worktree cwd inside a removing worktree', async () => {
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn(args: {
cols: number
rows: number
cwd?: string
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const removal = acquireWatcherRemovalGate('/repo/app')
await removal.ready
try {
await expect(
controller.spawn({
cols: 80,
rows: 24,
cwd: '/repo/app/nested',
worktreeId: 'repo-1::/repo/sibling',
env: {}
})
).rejects.toMatchObject({ code: 'terminal_removal_in_progress' })
expect(spawnMock).not.toHaveBeenCalled()
} finally {
removal.release()
}
})
it('falls back to the worktree root when a saved local cwd no longer exists', async () => {
registerPtyHandlers(mainWindow as never)
// Why: issue #7239 reproduced in a Japanese-named worktree; the fallback must return the selected worktree path verbatim.
const worktreePath = '/Users/motoki/orca/workspaces/nakamuramotoki/Fableと議論'
const missingCwd = `${worktreePath}/deleted-folder`
statSyncMock.mockImplementation((target: string) => {
if (target === missingCwd) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true, mode: 0o755 }
})
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: missingCwd,
cwdFallback: 'worktree',
worktreeId: `repo-1::${worktreePath}`
})) as { startupCwdFallback?: { kind: string; cwd: string } }
const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
expect(options.cwd).toBe(worktreePath)
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
})
it('keeps a missing cwd unchanged without the fallback flag', async () => {
registerPtyHandlers(mainWindow as never)
existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder')
statSyncMock.mockImplementation((target: string) => {
if (target === '/repo/app/deleted-folder') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true, mode: 0o755 }
})
// Why: without the renderer opt-in the provider surfaces its normal missing-directory error — API/runtime callers keep exact cwd semantics.
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/repo/app/deleted-folder',
worktreeId: 'repo-1::/repo/app'
})
).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.')
expect(spawnMock).not.toHaveBeenCalled()
})
it('spawns at an existing outside-worktree cwd without falling back (#7685)', async () => {
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/repo/app-other',
cwdFallback: 'worktree',
worktreeId: 'repo-1::/repo/app'
})) as { startupCwdFallback?: unknown }
const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
expect(options.cwd).toBe('/repo/app-other')
expect(result.startupCwdFallback).toBeUndefined()
})
it('ignores the cwd fallback flag for session reattach spawns', async () => {
registerPtyHandlers(mainWindow as never)
existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder')
statSyncMock.mockImplementation((target: string) => {
if (target === '/repo/app/deleted-folder') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return { isDirectory: () => true, mode: 0o755 }
})
// Why: a reattach must keep the session's exact cwd; remapping would silently detach the restored terminal from its recorded state.
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/repo/app/deleted-folder',
cwdFallback: 'worktree',
sessionId: 'session-1',
worktreeId: 'repo-1::/repo/app'
})
).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.')
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => {
const originalPlatform = process.platform
const originalUserProfile = process.env.USERPROFILE
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
process.env.USERPROFILE = 'C:\\Users\\jinwo'
// Why: the startup-cwd guard normalizes separators, so the provider sees the forward-slash UNC form.
existsSyncMock.mockImplementation((targetPath: string) => {
if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') {
return false
}
return true
})
try {
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\jin'
})
).rejects.toThrow(
'Working directory "//wsl.localhost/Ubuntu/home/jin/missing" does not exist.'
)
expect(spawnMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
}
})
it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => {
const originalPlatform = process.platform
const originalHome = process.env.HOME
const originalOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
// Why: this test simulates macOS even when Vitest runs on a Windows host.
process.env.HOME = '/Users/test'
delete process.env.ORCA_ORIG_ZDOTDIR
process.env.SHELL = '/bin/zsh'
delete process.env.ZDOTDIR
try {
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
command: 'printf "hello"'
})
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalOrcaOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = originalOrcaOrigZdotdir
}
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
if (originalZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = originalZdotdir
}
}
})
posixOnlyIt('wraps macOS spawns in login(1) with SHELL re-asserted via env(1)', async () => {
const originalShell = process.env.SHELL
// Re-enable the TCC login wrapper the suite-level beforeEach disables.
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
process.env.SHELL = '/bin/zsh'
loginPreflightExecFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void
) => {
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
return { stdin: { end: vi.fn() } }
}
)
resetMacosLoginShellPreflightForTests()
try {
const [file, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
expect(file).toBe('/usr/bin/login')
expect(args).toEqual([
'-flpq',
userInfo().username,
'/usr/bin/env',
'SHELL=/bin/zsh',
'/bin/zsh',
'-l'
])
// The spawn env keeps the real shell so identity/name logic is intact.
expect(options.env.SHELL).toBe('/bin/zsh')
} finally {
resetMacosLoginShellPreflightForTests()
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/zsh'
try {
const [shell, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('uses the POSIX shell wrapper so Pi config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/zsh'
openCodeBuildPtyEnvMock.mockImplementationOnce(() => ({
ORCA_OPENCODE_HOOK_PORT: '4567',
ORCA_OPENCODE_HOOK_TOKEN: 'opencode-token',
ORCA_OPENCODE_PTY_ID: 'test-pty'
}))
try {
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }
})
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(options.env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('does not force ~/.bashrc after sourcing bash login files in the shell-ready rcfile', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'darwin'
})
process.env.SHELL = '/bin/bash'
try {
await spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
const { getBashShellReadyRcfileContent } = await import('./pty')
const bashRcContent = getBashShellReadyRcfileContent()
expect(bashRcContent).toContain('source "$HOME/.bash_profile"')
expect(bashRcContent).not.toContain('source "$HOME/.bashrc"')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
posixOnlyIt(
'does not write the startup command before the shell-ready marker arrives',
async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: 'claude'
})
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('last login: today\r\n')
vi.runOnlyPendingTimers()
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('\x1b]133;A\x07% ')
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n')
} finally {
vi.useRealTimers()
}
}
)
posixOnlyIt(
'uses the no-marker wrapper and writes quickly for Codex startup commands',
async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: 'codex'
})
const [, , options] = spawnMock.mock.calls[0]!
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')
await Promise.resolve()
vi.advanceTimersByTime(49)
await Promise.resolve()
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('codex\n')
} finally {
vi.useRealTimers()
}
}
)
posixOnlyIt('waits for shell-ready before writing delivery-hinted Codex startup', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: "codex 'linked issue context'",
startupCommandDelivery: 'shell-ready'
})
const [, , options] = spawnMock.mock.calls[0]!
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1')
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('last login: today\r\n')
vi.advanceTimersByTime(1499)
await Promise.resolve()
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('\x1b]777;orca-shell-ready\x07')
await Promise.resolve()
vi.advanceTimersByTime(50)
await Promise.resolve()
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(150)
await Promise.resolve()
expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n")
} finally {
vi.useRealTimers()
}
})
posixOnlyIt(
'uses the short settle path for delivery-hinted Codex when prompt follows the marker',
async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: "codex 'linked issue context'",
startupCommandDelivery: 'shell-ready'
})
mockProc.emitData('\x1b]777;orca-shell-ready\x07\r\nuser@host % ')
await Promise.resolve()
vi.advanceTimersByTime(29)
await Promise.resolve()
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n")
} finally {
vi.useRealTimers()
}
}
)
posixOnlyIt('waits for shell-ready when Codex uses the native prefill flag', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: "codex --prefill 'linked issue context'"
})
const [, , options] = spawnMock.mock.calls[0]!
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('1')
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('\x1b]777;orca-shell-ready\x07')
await Promise.resolve()
vi.runAllTimers()
await Promise.resolve()
expect(mockProc.proc.write).toHaveBeenCalledWith("codex --prefill 'linked issue context'\n")
} finally {
vi.useRealTimers()
}
})
posixOnlyIt('keeps the conservative max wait for non-agent startup commands', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
command: 'printf "hello"'
})
vi.advanceTimersByTime(1499)
await Promise.resolve()
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('printf "hello"\n')
} finally {
vi.useRealTimers()
}
})
it('batches PTY output when it is not responding to recent input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
mockProc.emitData('background output')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'background output'
})
} finally {
vi.useRealTimers()
}
})
it('preserves background-origin metadata when hidden output flushes after resume', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setRendererPtyVisible = getPtySetRendererPtyVisibleListener()
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
mockProc.emitData('visible output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible output'
})
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: false })
mockProc.emitData('\x1b[2Khidden-width redraw')
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: '\x1b[2Khidden-width redraw',
background: true
})
} finally {
vi.useRealTimers()
}
})
it('marks visible renderer PTYs hidden while the renderer lifecycle resets', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setRendererPtyVisible = getPtySetRendererPtyVisibleListener()
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener()
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
handleRendererLoading()
// Reloaded page's dispatcher re-registers, releasing held sends (§1b).
handleRendererDispatcherReady()
mockProc.emitData('reload-gap output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'reload-gap output',
background: true
})
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
mockProc.emitData('visible output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible output'
})
} finally {
vi.useRealTimers()
}
})
it('resets leaked delivery accounting on renderer lifecycle reset so a saturated PTY resumes', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener()
// Drain the initial dispatcher-ready flush (beforeEach fires the handshake to model a live page) so flood timing starts clean.
vi.advanceTimersByTime(1)
mainWindow.webContents.send.mockClear()
// Saturate the PTY past the 512 KB per-PTY high-water with no ACKs.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
// Gate closed: sends stop at the cap and the remainder accrues as pending.
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
expect(vi.getTimerCount()).toBe(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024,
pendingPtyCount: 1,
rendererLifecycleResetCount: 0,
lastLifecycleResetClearedChars: 0
})
// Renderer reload: the dead page never ACKs, so its in-flight/pending accounting must clear or the surviving PTY stays gated forever.
handleRendererLoading()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
rendererInFlightPtyCount: 0,
pendingChars: 0,
pendingPtyCount: 0,
rendererLifecycleResetCount: 1,
lastLifecycleResetClearedChars: 512 * 1024
})
// Boot window (§1b): dispatcher not re-registered, so sends must be held — bytes into the listener-less page drop yet count in-flight, re-pinning the gate.
mainWindow.webContents.send.mockClear()
mockProc.emitData('post-reload output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
pendingChars: 'post-reload output'.length,
pendingPtyCount: 1
})
// The dispatcher-ready handshake releases the held backlog; assert delivery actually resumes and pending drains, not just counters-zero.
handleRendererDispatcherReady()
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'post-reload output'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 'post-reload output'.length,
pendingChars: 0,
pendingPtyCount: 0
})
} finally {
vi.useRealTimers()
}
})
it('ignores a subframe did-start-loading (isLoadingMainFrame false) so an in-page iframe load cannot freeze delivery', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const ackData = getPtyAckDataListener()
// Drain the initial dispatcher-ready flush (beforeEach fires the handshake).
vi.advanceTimersByTime(1)
mainWindow.webContents.send.mockClear()
// Saturate the PTY past the 512 KB per-PTY high-water with no ACKs.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
// A subframe load (isLoadingMainFrame() === false, e.g. notebook srcDoc iframe) is NOT a lifecycle reset — resetting here would freeze every pane for the watchdog window.
mainWindow.webContents.isLoadingMainFrame.mockReturnValueOnce(false)
handleRendererLoading()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024,
pendingPtyCount: 1,
rendererLifecycleResetCount: 0,
lastLifecycleResetClearedChars: 0,
rendererPtyDispatcherReady: true
})
// Gate still open: ACKing the in-flight cap drains the held backlog (a spurious reset would have cleared pending/ready, so this send would never fire).
mainWindow.webContents.send.mockClear()
ackData(null, { id: spawnResult.id, charCount: 512 * 1024 })
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('reconciles stale delivery accounting when a fresh dispatcher-ready handshake arrives while the gate is still open (missed lifecycle reset)', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener()
const ackData = getPtyAckDataListener()
// Drain the initial dispatcher-ready flush (beforeEach fires the handshake).
vi.advanceTimersByTime(1)
mainWindow.webContents.send.mockClear()
// Saturate the PTY past the 512 KB per-PTY high-water with no ACKs.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024,
rendererLifecycleResetCount: 0,
rendererPtyDispatcherReady: true
})
// Handshake arriving while the gate is already open proves a missed lifecycle reset (subframe-overlapped reload emits no did-start-loading); it must reconcile stale accounting or survivors stay pinned at the cap forever.
mainWindow.webContents.send.mockClear()
handleRendererDispatcherReady()
const reconciled = getPtyRendererDeliveryDebugSnapshot()
expect(reconciled).toMatchObject({
rendererInFlightChars: 0,
pendingChars: 0,
pendingPtyCount: 0,
rendererLifecycleResetCount: 1,
rendererPtyDispatcherReady: true
})
expect(reconciled.lastLifecycleResetClearedChars).toBeGreaterThan(0)
// Delivery has resumed: fresh output flows immediately instead of piling up behind the stale cap.
mockProc.emitData('post-reconcile output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'post-reconcile output'
})
// A straggler ACK from the dead page is clamped and cannot underflow the reconciled counters below zero.
ackData(null, { id: spawnResult.id, charCount: 512 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot().rendererInFlightChars).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('holds interactive input echo during the boot window until the dispatcher-ready handshake', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener()
const writeListener = getPtyWriteListener()
// Drain the initial ready-flush the beforeEach handshake schedules.
vi.advanceTimersByTime(1)
// Reload closes the gate; the reloaded page's dispatcher has not re-registered.
handleRendererLoading()
mainWindow.webContents.send.mockClear()
// With shouldSendInteractiveOutputNow() true, only the `&& rendererPtyDispatcherReady` guard keeps the interactive echo out of the still-listener-less page.
const redraw = '\x1b[20;2Hredraw'
writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' })
mockProc.emitData(redraw)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
pendingChars: redraw.length,
pendingPtyCount: 1
})
// The handshake releases the held echo (drained via the batch flush).
handleRendererDispatcherReady()
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: redraw
})
} finally {
vi.useRealTimers()
}
})
it('force-opens the delivery gate if no dispatcher-ready handshake arrives after a reload', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
vi.advanceTimersByTime(1)
// Reload closes the gate and arms the ~10s watchdog; the reloaded page never sends the handshake (dropped IPC), so output stays held.
handleRendererLoading()
mainWindow.webContents.send.mockClear()
mockProc.emitData('post-reload output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: false,
rendererDispatcherReadyForcedCount: 0
})
// Past the 10s watchdog window the gate self-heals (ready forced, backlog drains) instead of freezing permanently.
vi.advanceTimersByTime(10_000)
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'post-reload output'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: true,
rendererDispatcherReadyForcedCount: 1
})
} finally {
vi.useRealTimers()
}
})
it('ignores a dispatcher-ready handshake from a sender other than the main window', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const readyCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:rendererDispatcherReady'
)!
const rawReadyListener = readyCall[1] as (event: unknown) => void
vi.advanceTimersByTime(1)
// Why: a straggler handshake from a dying window must not reopen the gate (or trigger the destructive reconcile) for the new page.
handleRendererLoading()
mainWindow.webContents.send.mockClear()
rawReadyListener({ sender: { isDestroyed: () => false } })
mockProc.emitData('post-reload output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: false
})
// The genuine main-window handshake still opens the gate and drains.
rawReadyListener({ sender: mainWindow.webContents })
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'post-reload output'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: true
})
} finally {
vi.useRealTimers()
}
})
it('cancels the dispatcher-ready watchdog when the handshake arrives in time', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' })
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener()
vi.advanceTimersByTime(1)
// A timely handshake must cancel the reload watchdog so no orphaned ~10s timer lingers (forced-count guard can't catch it — the watchdog no-ops once ready).
handleRendererLoading()
handleRendererDispatcherReady()
vi.advanceTimersByTime(0)
expect(vi.getTimerCount()).toBe(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: true,
rendererDispatcherReadyForcedCount: 0
})
// Advancing well past the watchdog window leaves the forced counter at zero.
vi.advanceTimersByTime(20_000)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererPtyDispatcherReady: true,
rendererDispatcherReadyForcedCount: 0
})
} finally {
vi.useRealTimers()
}
})
it("cancels a prior registration's armed dispatcher-ready watchdog when handlers re-register (no orphaned timer across window re-creation)", async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' })
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
// Drain the initial dispatcher-ready flush; the baseline is timer-free.
vi.advanceTimersByTime(1)
expect(vi.getTimerCount()).toBe(0)
// A reload closes the gate and arms the ~10s self-heal watchdog on THIS registration's closure.
handleRendererLoading()
expect(vi.getTimerCount()).toBe(1)
// Re-registering must cancel the prior closure's watchdog (cross-registration bridge) or it later force-opens a dead window's gate.
registerPtyHandlers(mainWindow as never)
expect(vi.getTimerCount()).toBe(0)
// And no orphaned ~10s watchdog fires later (removing the bridge cancel turns this red).
vi.advanceTimersByTime(20_000)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('preserves background-origin metadata for repaint output caused by a hidden resize', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setRendererPtyVisible = getPtySetRendererPtyVisibleListener()
const resizePty = getPtyResizeListener()
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: false })
resizePty(null, { id: spawnResult.id, cols: 72, rows: 24 })
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
mockProc.emitData('\x1b[2Khidden-resize redraw')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: '\x1b[2Khidden-resize redraw',
background: true
})
mainWindow.webContents.send.mockClear()
resizePty(null, { id: spawnResult.id, cols: 80, rows: 24 })
mockProc.emitData('visible repaint')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible repaint'
})
} finally {
vi.useRealTimers()
}
})
it('does not keep hidden resize metadata after visible user input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setRendererPtyVisible = getPtySetRendererPtyVisibleListener()
const resizePty = getPtyResizeListener()
const writePty = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
setRendererPtyVisible(null, { id: spawnResult.id, visible: false })
resizePty(null, { id: spawnResult.id, cols: 72, rows: 24 })
setRendererPtyVisible(null, { id: spawnResult.id, visible: true })
writePty(mainWindowIpcEvent, { id: spawnResult.id, data: 'x' })
mockProc.emitData('x')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'x'
})
} finally {
vi.useRealTimers()
}
})
it('answers agent startup OSC color queries before renderer batching', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
launchAgent: 'codex',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
mockProc.proc.write.mockClear()
mainWindow.webContents.send.mockClear()
const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready'
mockProc.emitData(sourceData)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'ready',
rawLength: sourceData.length,
transformed: true
})
} finally {
vi.useRealTimers()
}
})
it('answers combined agent startup OSC foreground and background color queries', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
launchAgent: 'codex',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
mockProc.proc.write.mockClear()
mainWindow.webContents.send.mockClear()
const sourceData = '\x1b]10;?;?\x1b\\ready'
mockProc.emitData(sourceData)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'ready',
rawLength: sourceData.length,
transformed: true
})
} finally {
vi.useRealTimers()
}
})
it('does not answer ordinary terminal OSC color queries in main', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
mainWindow.webContents.send.mockClear()
const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\'
mockProc.emitData(`${query}ready`)
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: `${query}ready`
})
} finally {
vi.useRealTimers()
}
})
it('does not answer agent OSC color commands that only start like startup queries', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
launchAgent: 'codex',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
mockProc.proc.write.mockClear()
mainWindow.webContents.send.mockClear()
const command = '\x1b]10;?not-a-query\x1b\\'
mockProc.emitData(`${command}ready`)
expect(mockProc.proc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: `${command}ready`
})
} finally {
vi.useRealTimers()
}
})
it('accepts source-classified daemon startup spans before spawn resolves', async () => {
vi.useFakeTimers()
type ProviderData = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
let dataHandler: ((payload: ProviderData) => void) | null = null
const write = vi.fn()
const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\'
const spawn = vi.fn(async (options: { sessionId?: string; startupIngress?: unknown }) => {
const id = options.sessionId ?? 'daemon-pty'
dataHandler?.({
id,
data: '',
sequenceChars: query.length,
transformed: true,
seq: query.length
})
dataHandler?.({ id, data: 'daemon-ready' })
return { id }
})
setLocalPtyProvider({
spawn,
write,
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((handler: (payload: ProviderData) => void) => {
dataHandler = handler
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
try {
let seq = 0
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
onPtyData: vi.fn(
(_id: string, _data: string, _at: number, rawLength: number) => (seq += rawLength)
),
registerPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
launchAgent: 'codex',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
startupIngress: expect.objectContaining({
colors: { foreground: '#eeeeee', background: '#111111' },
deadlineMs: 5_000
})
})
)
expect(write).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'daemon-ready',
seq: query.length + 'daemon-ready'.length,
rawLength: query.length + 'daemon-ready'.length,
transformed: true
})
} finally {
vi.useRealTimers()
}
})
it('preserves source raw sequence metadata when a consumed query is batched', async () => {
vi.useFakeTimers()
type ProviderData = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
const providerEvents: {
dataHandler?: (payload: ProviderData) => void
} = {}
const write = vi.fn()
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
id: options.sessionId ?? 'daemon-pty'
}))
setLocalPtyProvider({
spawn,
write,
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((handler: (payload: ProviderData) => void) => {
providerEvents.dataHandler = handler
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
let seq = 0
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
onPtyData: vi.fn((_id: string, data: string, _at: number, rawLength = data.length) => {
seq += rawLength
return seq
}),
registerPty: vi.fn()
}
try {
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
launchAgent: 'codex',
terminalColorQueryReplies: {
foreground: '#eeeeee',
background: '#111111'
}
})) as { id: string }
mainWindow.webContents.send.mockClear()
providerEvents.dataHandler?.({ id: spawnResult.id, data: 'prefix' })
const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\'
providerEvents.dataHandler?.({
id: spawnResult.id,
data: '',
sequenceChars: query.length,
transformed: true,
seq: 'prefix'.length + query.length
})
providerEvents.dataHandler?.({ id: spawnResult.id, data: 'ready' })
vi.advanceTimersByTime(2)
expect(write).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'prefixready',
seq: 'prefix'.length + query.length + 'ready'.length,
rawLength: 'prefix'.length + query.length + 'ready'.length,
transformed: true
})
} finally {
vi.useRealTimers()
}
})
it('sends small PTY redraws immediately after terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
mockProc.emitData('\x1b[20;2Hredraw')
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: '\x1b[20;2Hredraw'
})
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('ignores PTY input for unknown sessions', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: 'missing-pty',
data: 'a'
})
expect(mockProc.proc.write).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('batches large PTY output even after recent terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const largeOutput = 'x'.repeat(1025)
mockProc.emitData(largeOutput)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: largeOutput
})
} finally {
vi.useRealTimers()
}
})
it('batches repeated small PTY chunks after the interactive output budget is exhausted', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const smallChunk = 'x'.repeat(512)
for (let index = 0; index < 65; index++) {
mockProc.emitData(smallChunk)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64)
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', {
id: spawnResult.id,
data: smallChunk
})
} finally {
vi.useRealTimers()
}
})
it('sends larger ANSI redraws immediately after terminal input', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
mainWindow.webContents.send.mockClear()
const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(80)}`
mockProc.emitData(redraw)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: redraw
})
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('batches combined pending output that exceeds the interactive size limit', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
const pendingOutput = 'x'.repeat(1020)
mockProc.emitData(pendingOutput)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
mockProc.emitData('redraw')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: `${pendingOutput}redraw`
})
} finally {
vi.useRealTimers()
}
})
it('drains large batched PTY output in bounded slices', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
const firstChunk = 'x'.repeat(16 * 1024)
const firstRemainder = 'tail'
secondProc.emitData('second-terminal-output')
firstProc.emitData(`${firstChunk}${firstRemainder}`)
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', {
id: secondSpawn.id,
data: 'second-terminal-output'
})
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', {
id: firstSpawn.id,
data: firstChunk
})
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(3)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(3, 'pty:data', {
id: firstSpawn.id,
data: firstRemainder
})
} finally {
vi.useRealTimers()
}
})
it('defers reentrant new, unvisited, and same-partial output to the next drain round', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
const newProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc)
spawnMock.mockReturnValueOnce(secondProc.proc)
spawnMock.mockReturnValueOnce(newProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const newSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const firstChunk = 'x'.repeat(16 * 1024)
const setActiveRendererPty = getPtySetActiveRendererPtyListener()
let reentered = false
mainWindow.webContents.send.mockImplementation(
(channel: string, payload: { id?: string }) => {
if (channel !== 'pty:data' || payload.id !== firstSpawn.id || reentered) {
return
}
reentered = true
firstProc.emitData('+same')
secondProc.emitData('+append')
newProc.emitData('new')
setActiveRendererPty(null, { id: newSpawn.id, active: true })
}
)
firstProc.emitData(`${firstChunk}tail`)
secondProc.emitData('second')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: firstSpawn.id, data: firstChunk }]])
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls().slice(1)).toEqual([
['pty:data', { id: newSpawn.id, data: 'new' }],
['pty:data', { id: secondSpawn.id, data: 'second+append' }]
])
vi.advanceTimersByTime(1)
expect(getPtyDataSendCalls().at(-1)).toEqual([
'pty:data',
{ id: firstSpawn.id, data: 'tail+same' }
])
} finally {
vi.useRealTimers()
}
})
it('commits a partial remainder before notification-reentrant exit', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string; incarnationId: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const firstChunk = 'x'.repeat(16 * 1024)
mainWindow.webContents.send.mockClear()
let exited = false
mainWindow.webContents.send.mockImplementation(
(channel: string, payload: { id?: string; data?: string }) => {
if (
channel === 'pty:data' &&
payload.id === firstSpawn.id &&
payload.data === firstChunk &&
!exited
) {
exited = true
firstProc.emitExit(0)
}
}
)
firstProc.emitData(`${firstChunk}tail`)
secondProc.emitData('next')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send.mock.calls).toEqual([
['pty:data', { id: firstSpawn.id, data: firstChunk }],
['pty:data', { id: firstSpawn.id, data: 'tail' }],
['pty:exit', { id: firstSpawn.id, code: 0, incarnationId: firstSpawn.incarnationId }],
['pty:data', { id: secondSpawn.id, data: 'next' }]
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererInFlightPtyCount: 1,
rendererInFlightChars: 'next'.length,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('aborts the open drain when renderer lifecycle clear reenters notification', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const detachedProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(detachedProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
let cleared = false
mainWindow.webContents.send.mockImplementation(
(channel: string, payload: { id?: string }) => {
if (channel === 'pty:data' && payload.id === firstSpawn.id && !cleared) {
cleared = true
handleRendererLoading()
}
}
)
firstProc.emitData('first')
detachedProc.emitData('must-not-send')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: firstSpawn.id, data: 'first' }]])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
flushScheduled: false,
rendererPtyDispatcherReady: false
})
expect(vi.getTimerCount()).toBe(1)
} finally {
vi.useRealTimers()
}
})
it('waits for renderer ACKs before sending more output for a saturated PTY', async () => {
vi.useFakeTimers()
const firstProc = createMockProc()
const secondProc = createMockProc()
spawnMock.mockReturnValueOnce(firstProc.proc).mockReturnValueOnce(secondProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const firstSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const secondSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
firstProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
expect(vi.getTimerCount()).toBe(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 88 * 1024,
maxPendingCharsByPty: 88 * 1024,
rendererInFlightPtyCount: 1,
rendererInFlightChars: 512 * 1024,
maxRendererInFlightCharsByPty: 512 * 1024,
flushScheduled: false,
peakPendingChars: 600 * 1024,
peakMaxPendingCharsByPty: 600 * 1024,
peakRendererInFlightChars: 512 * 1024,
peakMaxRendererInFlightCharsByPty: 512 * 1024,
ackGatedFlushSkipCount: 1
})
secondProc.emitData('second-terminal-output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', {
id: secondSpawn.id,
data: 'second-terminal-output'
})
ackData(null, { id: firstSpawn.id, charCount: 16 * 1024 })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(34)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(34, 'pty:data', {
id: firstSpawn.id,
data: 'x'.repeat(16 * 1024)
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 72 * 1024,
rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
peakPendingChars: 600 * 1024,
peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length
})
resetPtyRendererDeliveryDebug()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 72 * 1024,
rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
peakPendingChars: 72 * 1024,
peakMaxPendingCharsByPty: 72 * 1024,
peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length,
peakMaxRendererInFlightCharsByPty: 512 * 1024,
ackGatedFlushSkipCount: 0
})
} finally {
vi.useRealTimers()
}
})
it('does not scan delivery maps for 1,000 ACKs across 100 tracked PTYs', () => {
vi.useFakeTimers()
const provider = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never)
const ptyIds = Array.from({ length: 100 }, (_, index) => `pressure-pty-${index}`)
for (const id of ptyIds) {
provider.emitData(id, 'a')
}
vi.runAllTimers()
for (const id of ptyIds) {
provider.emitData(id, 'b')
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 100,
pendingChars: 100,
rendererInFlightPtyCount: 100,
rendererInFlightChars: 100
})
const ackData = getPtyAckDataListener()
const mapValuesSpy = vi.spyOn(Map.prototype, 'values')
let mapValuesCalls = 0
try {
for (let index = 0; index < 1_000; index++) {
ackData(null, { id: ptyIds[0]!, processedChars: 1 })
}
} finally {
mapValuesCalls = mapValuesSpy.mock.calls.length
mapValuesSpy.mockRestore()
}
expect(mapValuesCalls).toBe(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 100,
pendingChars: 100,
rendererInFlightPtyCount: 99,
rendererInFlightChars: 99
})
} finally {
vi.useRealTimers()
}
})
it('caps per-PTY pending output while the renderer is starved and heals via a droppedOutput sentinel', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
// Saturate the renderer in-flight window (512 KB) with no ACKs — the frozen/starved-renderer shape from field reports.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 32; index++) {
vi.advanceTimersByTime(1)
}
// Keep flooding past the 2 MB per-PTY pending cap; main must not buffer unboundedly (previously: unbounded string concat).
mockProc.emitData('y'.repeat(3 * 1024 * 1024))
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0
})
// Later output while dropped must stay O(1), not start re-accumulating.
mockProc.emitData('z'.repeat(64 * 1024))
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0
})
// On recover+ACK, the flush must deliver the droppedOutput sentinel so the pane repaints from the main-owned snapshot.
mainWindow.webContents.send.mockClear()
ackData(null, { id: spawn.id, charCount: 512 * 1024 })
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawn.id,
data: '',
droppedOutput: true
})
// Fresh output after the sentinel flows normally again.
mainWindow.webContents.send.mockClear()
mockProc.emitData('back to normal')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawn.id,
data: 'back to normal'
})
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('carves reply-eliciting queries out of a pending-cap bulk drop so probes survive', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
// Saturate the in-flight window so everything after buffers in pendingData.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 32; index++) {
vi.advanceTimersByTime(1)
}
// Flood past the pending cap WITH an embedded DSR probe — the writing program blocks on the reply (bench DSR timeout).
mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`)
// While latched, a later probe must also be carved out (bounded).
mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`)
mainWindow.webContents.send.mockClear()
ackData(null, { id: spawn.id, charCount: 512 * 1024 })
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawn.id,
data: '\x1b[6n\x1b[0c',
droppedOutput: true
})
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('scales the pending-output cap with the scrollback setting', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
// 50k-row scrollback ⇒ 6 MB pending cap instead of the 2 MB floor.
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({
terminalScrollbackRows: 50_000
})) as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' })
mainWindow.webContents.send.mockClear()
// Saturate the in-flight window with no ACKs, then buffer 3 MB — over the floor, under the scaled cap: retain, don't drop.
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 32; index++) {
vi.advanceTimersByTime(1)
}
mockProc.emitData('y'.repeat(3 * 1024 * 1024))
expect(getPtyRendererDeliveryDebugSnapshot().pendingChars).toBeGreaterThan(3 * 1024 * 1024)
// The scaled cap still bounds a runaway flood.
mockProc.emitData('z'.repeat(4 * 1024 * 1024))
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0
})
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('pauses the producer at the pending high watermark and resumes after drain', async () => {
vi.useFakeTimers()
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
// Flood in 64KB chunks like a `yes`-style producer honoring pause — node-pty pause() stops the fd read, so it stops emitting.
const chunk = 'x'.repeat(64 * 1024)
let chunks = 0
while (provider.pauseProducer.mock.calls.length === 0 && chunks < 100) {
provider.emitData('flood-pty', chunk)
chunks++
}
// Pause fires exactly once, on the first chunk past the 256KB high watermark (the 5th 64KB chunk), not per chunk.
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
expect(provider.pauseProducer).toHaveBeenCalledWith('flood-pty')
expect(chunks).toBe(5)
// Bounded: main buffered at most HIGH + one chunk while paused.
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 320 * 1024,
peakPendingChars: 320 * 1024
})
// Resume must fire exactly once at the 32KB low watermark, with no flapping across the 32-256KB hysteresis band.
vi.runAllTimers()
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty')
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingChars: 0 })
} finally {
vi.useRealTimers()
}
})
it('resumes a paused producer when the PTY exits before draining', async () => {
vi.useFakeTimers()
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
const finalPendingData = 'x'.repeat(320 * 1024)
provider.emitData('flood-pty', finalPendingData)
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
// Exit while pending is above the low watermark: the exit path must release the pause, not leave a stale mark.
provider.emitExit('flood-pty', 0)
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty')
} finally {
vi.useRealTimers()
}
})
it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => {
vi.useFakeTimers()
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
const finalPendingData = 'x'.repeat(320 * 1024)
provider.emitData('flood-pty', finalPendingData)
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
provider.resumeProducer.mockImplementation((id: string) => {
provider.emitData(id, 'must-not-follow-exit')
provider.emitExit(id, 0)
})
provider.emitExit('flood-pty', 0)
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send.mock.calls).toEqual([
['pty:data', { id: 'flood-pty', data: finalPendingData }],
['pty:exit', { id: 'flood-pty', code: 0 }]
])
expect(
getPtyDataSendCalls().some(
(call) => (call[1] as { data?: string } | undefined)?.data === 'must-not-follow-exit'
)
).toBe(false)
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toHaveLength(1)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('does not retry a complete payload after a synchronous renderer send failure', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
let failed = false
let markerFailed = false
mainWindow.webContents.send.mockImplementation(
(channel: string, payload: { id?: string }) => {
if (channel === 'pty:data' && payload.id === 'send-fail-complete' && !failed) {
failed = true
throw new Error('synthetic send failure')
}
if (channel === 'pty:modelRestoreNeeded' && !markerFailed) {
markerFailed = true
throw new Error('synthetic marker failure')
}
}
)
provider.emitData('send-fail-complete', 'lost-once')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([
['pty:data', { id: 'send-fail-complete', data: 'lost-once' }]
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
pendingChars: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
provider.emitData('send-fail-complete', 'recovery')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([
['pty:data', { id: 'send-fail-complete', data: 'lost-once' }],
['pty:data', { id: 'send-fail-complete', data: 'recovery' }]
])
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: 'send-fail-complete',
reason: 'delivery-heal'
})
provider.emitData('send-fail-complete', 'after-marker-failure')
vi.advanceTimersByTime(2)
expect(
mainWindow.webContents.send.mock.calls.filter(
(call) => call[0] === 'pty:modelRestoreNeeded'
)
).toHaveLength(2)
expect(getPtyDataSendCalls().at(-1)).toEqual([
'pty:data',
{ id: 'send-fail-complete', data: 'after-marker-failure' }
])
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('keeps only a partial remainder after a synchronous renderer send failure', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
const firstChunk = 'x'.repeat(16 * 1024)
let failed = false
mainWindow.webContents.send.mockImplementation((channel: string) => {
if (channel === 'pty:data' && !failed) {
failed = true
throw new Error('synthetic send failure')
}
})
provider.emitData('send-fail-partial', `${firstChunk}tail`)
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([
['pty:data', { id: 'send-fail-partial', data: firstChunk }]
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 4,
rendererInFlightChars: 0,
flushScheduled: true
})
expect(vi.getTimerCount()).toBe(1)
vi.advanceTimersByTime(1)
expect(getPtyDataSendCalls()).toEqual([
['pty:data', { id: 'send-fail-partial', data: firstChunk }],
['pty:data', { id: 'send-fail-partial', data: 'tail' }]
])
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: 'send-fail-partial',
reason: 'delivery-heal'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererInFlightChars: 4,
flushScheduled: false
})
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('clears failed-delivery restore state when the renderer lifecycle resets', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
const resetRenderer = getMainWindowWebContentsListener('did-start-loading')
const readyRenderer = getPtyRendererDispatcherReadyListener()
let failed = false
mainWindow.webContents.send.mockImplementation((channel: string) => {
if (channel === 'pty:data' && !failed) {
failed = true
throw new Error('synthetic send failure')
}
})
provider.emitData('send-fail-reset', 'lost-once')
vi.advanceTimersByTime(2)
resetRenderer()
readyRenderer()
mainWindow.webContents.send.mockClear()
provider.emitData('send-fail-reset', 'repainted-page-data')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toEqual([
['pty:data', { id: 'send-fail-reset', data: 'repainted-page-data' }]
])
expect(
mainWindow.webContents.send.mock.calls.filter(
(call) => call[0] === 'pty:modelRestoreNeeded'
)
).toHaveLength(0)
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('commits interactive bypass removal and producer flow after a synchronous send failure', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writePty = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
let failed = false
mainWindow.webContents.send.mockImplementation((channel: string) => {
if (channel === 'pty:data' && !failed) {
failed = true
throw new Error('synthetic send failure')
}
})
mockProc.emitData('older-')
writePty(mainWindowIpcEvent, { id: spawn.id, data: 'x' })
mockProc.emitData('redraw')
expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: spawn.id, data: 'older-redraw' }]])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
pendingChars: 0,
rendererInFlightChars: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
mockProc.emitData('recovery')
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawn.id,
reason: 'delivery-heal'
})
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('cleans up and emits exit once when the final data send fails synchronously', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
const pending = 'x'.repeat(320 * 1024)
provider.emitData('send-fail-exit', pending)
expect(provider.pauseProducer).toHaveBeenCalledWith('send-fail-exit')
mainWindow.webContents.send.mockClear()
mainWindow.webContents.send.mockImplementation((channel: string) => {
if (channel === 'pty:data') {
throw new Error('synthetic send failure')
}
})
provider.emitExit('send-fail-exit', 7)
expect(getPtyDataSendCalls()).toEqual([['pty:data', { id: 'send-fail-exit', data: pending }]])
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'send-fail-exit', code: 7 }]])
expect(
mainWindow.webContents.send.mock.calls.filter(
(call) => call[0] === 'pty:modelRestoreNeeded'
)
).toHaveLength(0)
expect(provider.resumeProducer).toHaveBeenCalledWith('send-fail-exit')
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
pendingChars: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('delivers a pending-cap sentinel before exit and clears its pending timer', () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
provider.emitData('flood-pty', 'x'.repeat(3 * 1024 * 1024))
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0,
flushScheduled: true
})
mainWindow.webContents.send.mockClear()
provider.emitExit('flood-pty', 0)
expect(mainWindow.webContents.send.mock.calls).toEqual([
['pty:data', { id: 'flood-pty', data: '', droppedOutput: true }],
['pty:exit', { id: 'flood-pty', code: 0 }]
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
const DELIVERY_RESYNC_UNANSWERED_WARNING =
'[pty] delivery resync probe unanswered — renderer IPC unresponsive'
function countResyncUnansweredWarnings(warnSpy: { mock: { calls: unknown[][] } }): number {
return warnSpy.mock.calls.filter((call) => call[0] === DELIVERY_RESYNC_UNANSWERED_WARNING)
.length
}
function getPtyDataSendCalls(): unknown[][] {
return mainWindow.webContents.send.mock.calls.filter(
(call: unknown[]) => call[0] === 'pty:data'
)
}
function getDeliveryResyncProbeCalls(): unknown[][] {
return mainWindow.webContents.send.mock.calls.filter(
(call: unknown[]) => call[0] === 'pty:requestDeliveryResync'
)
}
function getDeliveryResyncResponseListener(): (
event: unknown,
args: { requestId: number; processedCharsByPty: Record<string, number> }
) => void {
const responseCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:deliveryResyncResponse'
)
if (!responseCall) {
throw new Error('missing pty:deliveryResyncResponse listener')
}
return responseCall[1] as (
event: unknown,
args: { requestId: number; processedCharsByPty: Record<string, number> }
) => void
}
/** Saturates one PTY to its 512 KiB in-flight cap; leaves 88 KiB pending and no timers scheduled. */
async function spawnAndSaturateRendererDeliveryGate(
mockProc: ReturnType<typeof createMockProc>
): Promise<{ id: string }> {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 32; index++) {
vi.advanceTimersByTime(1)
}
return spawnResult
}
it('self-heals lost ACKs when a later cumulative ACK arrives', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
expect(getPtyDataSendCalls()).toHaveLength(32)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
rendererInFlightPtyCount: 1
})
// Every per-chunk ACK was lost, but the next ACK carries the full cumulative total — the debt clears without any timer or reset.
const ackData = getPtyAckDataListener()
ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
rendererInFlightPtyCount: 0
})
vi.runOnlyPendingTimers()
expect(getPtyDataSendCalls()).toHaveLength(33)
} finally {
vi.useRealTimers()
}
})
it('applies cumulative ACKs idempotently and ignores stale reordered totals', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
const ackData = getPtyAckDataListener()
ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 256 * 1024,
maxRendererInFlightCharsByPty: 256 * 1024
})
// Replayed duplicate credits nothing further.
ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 256 * 1024
})
// A stale reordered total can never move accounting backwards.
ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 256 * 1024
})
} finally {
vi.useRealTimers()
}
})
it('keeps zero, duplicate, and stale ACKs to one legacy no-write timer', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
const ackData = getPtyAckDataListener()
expect(getPtyDataSendCalls()).toHaveLength(32)
expect(vi.getTimerCount()).toBe(0)
ackData(null, { id: spawnResult.id, processedChars: 0 })
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true)
expect(vi.getTimerCount()).toBe(1)
ackData(null, { id: spawnResult.id, processedChars: 0 })
ackData(null, { id: spawnResult.id, processedChars: -1 })
expect(vi.getTimerCount()).toBe(1)
vi.runOnlyPendingTimers()
expect(getPtyDataSendCalls()).toHaveLength(32)
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false)
expect(vi.getTimerCount()).toBe(0)
ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 })
vi.runOnlyPendingTimers()
expect(getPtyDataSendCalls()).toHaveLength(33)
} finally {
vi.useRealTimers()
}
})
it('tolerates mixed legacy delta and cumulative ACK payloads', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
const ackData = getPtyAckDataListener()
// Legacy delta shape (no processedChars) still credits per chunk.
ackData(null, { id: spawnResult.id, charCount: 16 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 496 * 1024
})
// A cumulative total then supersedes without double-crediting the delta.
ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
rendererInFlightPtyCount: 0
})
} finally {
vi.useRealTimers()
}
})
it('forwards only newly acknowledged cumulative bytes to provider ACK backpressure', async () => {
vi.useFakeTimers()
const acknowledgeDataEvent = vi.fn()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'cumulative-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((callback) => {
mockProc.proc.onData((data: string) => callback({ id: 'cumulative-pty', data }))
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
mockProc.emitData('remote-output')
vi.advanceTimersByTime(8)
// Why: cumulative totals clamp to what main sent; a replayed total credits SSH/relay flow control 0, not duplicate bytes.
ackData(null, { id: 'cumulative-pty', processedChars: 1024 })
ackData(null, { id: 'cumulative-pty', processedChars: 1024 })
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(
1,
'cumulative-pty',
'remote-output'.length
)
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'cumulative-pty', 0)
} finally {
vi.useRealTimers()
}
})
it('probes for a delivery resync when data arrives for a fully gated PTY and reconciles on reply', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
mockProc.emitData('stuck-output')
expect(getDeliveryResyncProbeCalls()).toHaveLength(1)
const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number }
// Only one probe may be outstanding at a time.
mockProc.emitData('still-stuck')
expect(getDeliveryResyncProbeCalls()).toHaveLength(1)
const respondDeliveryResync = getDeliveryResyncResponseListener()
respondDeliveryResync(null, {
requestId: probePayload.requestId,
processedCharsByPty: { [spawnResult.id]: 512 * 1024 }
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
rendererInFlightPtyCount: 0
})
// Reconciled gate lets held pendingData flush again (one 2ms batch window = one 16KB slice).
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toHaveLength(33)
} finally {
vi.useRealTimers()
}
})
it('ignores resync replies with stale request ids', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
mockProc.emitData('stuck-output')
const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number }
const respondDeliveryResync = getDeliveryResyncResponseListener()
respondDeliveryResync(null, {
requestId: probePayload.requestId + 41,
processedCharsByPty: { [spawnResult.id]: 512 * 1024 }
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024
})
} finally {
vi.useRealTimers()
}
})
it('clears an unanswered resync probe, warns once per silent streak, and never mutates counters', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
await spawnAndSaturateRendererDeliveryGate(mockProc)
mockProc.emitData('stuck-output')
expect(getDeliveryResyncProbeCalls()).toHaveLength(1)
vi.advanceTimersByTime(4_999)
expect(countResyncUnansweredWarnings(warnSpy)).toBe(0)
vi.advanceTimersByTime(1)
expect(countResyncUnansweredWarnings(warnSpy)).toBe(1)
expect(warnSpy).toHaveBeenCalledWith(
DELIVERY_RESYNC_UNANSWERED_WARNING,
expect.objectContaining({
rendererInFlightChars: 512 * 1024,
pendingPtyCount: 1
})
)
// No blind reset: counters and pending output are untouched.
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024 + 'stuck-output'.length
})
expect(getPtyDataSendCalls()).toHaveLength(32)
// Cleared flag lets the next gated arrival probe again, but a still-silent renderer won't spam a second warn.
mockProc.emitData('still-stuck')
expect(getDeliveryResyncProbeCalls()).toHaveLength(2)
vi.advanceTimersByTime(5_000)
expect(countResyncUnansweredWarnings(warnSpy)).toBe(1)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024
})
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
it('clears resync probe state when the window is destroyed', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
let destroyed = false
const destroyableWindow = {
isDestroyed: () => destroyed,
isFocused: () => true,
isVisible: () => true,
isMinimized: () => false,
webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() }
}
try {
registerPtyHandlers(destroyableWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' })
destroyableWindow.webContents.send.mockClear()
mockProc.emitData('x'.repeat(600 * 1024))
vi.advanceTimersByTime(8)
for (let index = 0; index < 32; index++) {
vi.advanceTimersByTime(1)
}
mockProc.emitData('stuck-output')
// Only the probe's hygiene timeout remains; the dispatcher-ready handshake already drained the pending flush.
expect(vi.getTimerCount()).toBe(1)
destroyed = true
mockProc.emitData('post-destroy output')
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(60_000)
expect(countResyncUnansweredWarnings(warnSpy)).toBe(0)
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
// ── Renderer-initiated delivery health/heal (pty:reportRendererDeliveryState) ──
// Field wedge (v1.4.121-rc.0): push dead but invoke alive, so the push-riding resync probe can't answer; this invoke lane recovers.
function reportRendererDeliveryState(args: {
receivedCharsByPty: Record<string, number>
processedCharsByPty: Record<string, number>
heal?: boolean
rendererPtyDataListenerCount?: number | null
}): {
inFlightTotalChars: number
inFlightPtyCount: number
msSinceLastAck: number | null
writtenOff?: { id: string; markerSeq?: number; writtenOffChars: number }[]
} {
const handler = handlers.get('pty:reportRendererDeliveryState')
if (!handler) {
throw new Error('missing pty:reportRendererDeliveryState handler')
}
return handler(null, args) as ReturnType<typeof reportRendererDeliveryState>
}
it('reports delivery health over invoke without mutating any delivery state', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
await spawnAndSaturateRendererDeliveryGate(mockProc)
// The field wedge in miniature: renderer received nothing, no ACK ever.
const health = reportRendererDeliveryState({
receivedCharsByPty: {},
processedCharsByPty: {}
})
expect(health).toMatchObject({
inFlightTotalChars: 512 * 1024,
inFlightPtyCount: 1,
msSinceLastAck: null
})
expect(health.writtenOff).toBeUndefined()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024
})
} finally {
vi.useRealTimers()
}
})
it('merges cumulative processed totals from a health report as a repair lane', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
// Lost-ACK variant: renderer processed everything, only ACKs vanished; a plain report (no heal) must drain the debt.
const health = reportRendererDeliveryState({
receivedCharsByPty: { [spawnResult.id]: 512 * 1024 },
processedCharsByPty: { [spawnResult.id]: 512 * 1024 }
})
expect(health).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 })
expect(health.writtenOff).toBeUndefined()
// Fully reopened gate drains one 16K slice per batcher tick (0/1/2 ms).
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toHaveLength(35)
} finally {
vi.useRealTimers()
}
})
it('heals a dead push channel: writes off unreceived bytes and returns restore markers', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
const healed = reportRendererDeliveryState({
receivedCharsByPty: {},
processedCharsByPty: {},
heal: true,
rendererPtyDataListenerCount: 1
})
// 512 KiB never-received is written off; the 88 KiB pending is dropped too (snapshot restore covers everything at/before the marker).
expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }])
expect(healed).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 0,
pendingChars: 0,
pendingDroppedChars: 88 * 1024
})
expect(warnSpy).toHaveBeenCalledWith(
'[pty] delivery heal: wrote off renderer-bound bytes lost in push channel',
expect.objectContaining({ rendererPtyDataListenerCount: 1 })
)
// Delivery is unwedged: fresh output flows to the renderer again.
mockProc.emitData('after-heal')
vi.advanceTimersByTime(2)
expect(getPtyDataSendCalls()).toHaveLength(33)
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
it('reactivates globally blocked work immediately after a delivery writeoff', () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
const bulkIds = Array.from({ length: 16 }, (_, index) => `writeoff-bulk-${index}`)
mainWindow.webContents.send.mockClear()
for (const id of bulkIds) {
provider.emitData(id, 'x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 8 * 1024 * 1024,
flushScheduled: false
})
provider.emitData('writeoff-held', 'held')
vi.advanceTimersByTime(2)
expect(
getPtyDataSendCalls().some(
(call) => (call[1] as { id?: string } | undefined)?.id === 'writeoff-held'
)
).toBe(false)
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false)
reportRendererDeliveryState({
receivedCharsByPty: {},
processedCharsByPty: {},
heal: true,
rendererPtyDataListenerCount: 1
})
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true)
vi.advanceTimersByTime(0)
expect(getPtyDataSendCalls().at(-1)).toEqual([
'pty:data',
{ id: 'writeoff-held', data: 'held' }
])
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
it('never writes off bytes the renderer received but has not parsed yet', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
// Parse backpressure, not a wedge: ACK credit is deferred to the scheduler consume point and still repays.
const health = reportRendererDeliveryState({
receivedCharsByPty: { [spawnResult.id]: 512 * 1024 },
processedCharsByPty: {},
heal: true,
rendererPtyDataListenerCount: 1
})
expect(health.writtenOff).toBeUndefined()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 512 * 1024,
pendingChars: 88 * 1024
})
} finally {
vi.useRealTimers()
}
})
it('refuses a heal while main has seen a recent ACK', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc)
const ackData = getPtyAckDataListener()
ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 })
// A pty still round-trips ACKs, so the channel isn't dead — a heal must not destroy accounting.
const blocked = reportRendererDeliveryState({
receivedCharsByPty: {},
processedCharsByPty: {},
heal: true
})
expect(blocked.writtenOff).toBeUndefined()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 496 * 1024
})
// Once main-side ACK silence crosses the floor, the same heal proceeds.
vi.advanceTimersByTime(10_000)
const healed = reportRendererDeliveryState({
receivedCharsByPty: {},
processedCharsByPty: {},
heal: true
})
expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }])
} finally {
vi.useRealTimers()
}
})
it('zeroes renderer in-flight delivery counters when the renderer lifecycle resets', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
await spawnAndSaturateRendererDeliveryGate(mockProc)
const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading')
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightPtyCount: 1,
rendererInFlightChars: 512 * 1024
})
handleRendererLoading()
// Why: reload kills the dispatcher that would ACK, so stale counters would gate PTYs in the fresh renderer forever.
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0
})
// Main holds sends until the replacement page confirms its dispatcher; the reset arms a bounded handshake watchdog.
expect(vi.getTimerCount()).toBe(1)
mockProc.emitData('after-reload')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
getPtyRendererDispatcherReadyListener()()
// One 2ms batch window releases the fresh page's held output.
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33)
} finally {
vi.useRealTimers()
}
})
it('forwards only actually in-flight bytes to provider ACK backpressure', async () => {
vi.useFakeTimers()
const acknowledgeDataEvent = vi.fn()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'remote-like-pty' })),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent,
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn((callback) => {
mockProc.proc.onData((data: string) => callback({ id: 'remote-like-pty', data }))
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
mockProc.emitData('remote-output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: 'remote-like-pty',
data: 'remote-output'
})
// Why: stale/duplicated renderer ACKs must not over-credit SSH/relay flow control beyond bytes main sent.
ackData(null, { id: 'remote-like-pty', charCount: 1024 })
ackData(null, { id: 'remote-like-pty', charCount: 1024 })
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(
1,
'remote-like-pty',
'remote-output'.length
)
expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'remote-like-pty', 0)
} finally {
vi.useRealTimers()
}
})
it('reserves a bounded renderer lane for interactive output when bulk output is saturated', async () => {
vi.useFakeTimers()
const bulkProcs = Array.from({ length: 16 }, () => createMockProc())
const interactiveProc = createMockProc()
for (const proc of [...bulkProcs, interactiveProc]) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
for (const _proc of bulkProcs) {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
}
const interactiveSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
mainWindow.webContents.send.mockClear()
for (const proc of bulkProcs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
expect(vi.getTimerCount()).toBe(0)
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData('\x1b[20;2Hredraw')
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', {
id: interactiveSpawn.id,
data: '\x1b[20;2Hredraw'
})
const reservePrefix = '\x1b[20;2H'
const reserveChunk = `${reservePrefix}${'r'.repeat(16 * 1024 - reservePrefix.length)}`
for (let index = 0; index < 16; index++) {
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData(reserveChunk)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529)
writeListener(mainWindowIpcEvent, {
id: interactiveSpawn.id,
data: 'a'
})
interactiveProc.emitData(reserveChunk)
// Why: reserve-exhausted send stays gated, and the fully gated arrival emits one delivery resync probe (not pty:data).
expect(getPtyDataSendCalls()).toHaveLength(529)
expect(getDeliveryResyncProbeCalls()).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('caps total renderer in-flight output across many PTYs', async () => {
vi.useFakeTimers()
const procs = Array.from({ length: 17 }, () => createMockProc())
for (const proc of procs) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const spawns: { id: string }[] = []
for (const _proc of procs) {
spawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
const ackData = getPtyAckDataListener()
mainWindow.webContents.send.mockClear()
for (const proc of procs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
ackData(null, { id: spawns[0].id, charCount: 16 * 1024 })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513)
} finally {
vi.useRealTimers()
}
})
it('reactivates every globally blocked PTY when an exit releases renderer credit', async () => {
vi.useFakeTimers()
const procs = Array.from({ length: 17 }, () => createMockProc())
for (const proc of procs) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const spawns: { id: string }[] = []
for (const _proc of procs) {
spawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
mainWindow.webContents.send.mockClear()
for (const proc of procs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyDataSendCalls()).toHaveLength(512)
expect(vi.getTimerCount()).toBe(0)
mainWindow.webContents.send.mockClear()
procs[0]!.emitExit(0)
const exitIndex = mainWindow.webContents.send.mock.calls.findIndex(
(call) => call[0] === 'pty:exit'
)
expect(exitIndex).toBeGreaterThanOrEqual(0)
vi.advanceTimersByTime(0)
expect(
mainWindow.webContents.send.mock.calls
.slice(exitIndex + 1)
.some(
(call) =>
call[0] === 'pty:data' &&
(call[1] as { id?: string } | undefined)?.id !== spawns[0]!.id
)
).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('wakes blocked PTYs when a zero-write hidden drop reentrantly releases exit credit', async () => {
vi.useFakeTimers()
const bulkProcs = Array.from({ length: 16 }, () => createMockProc())
const hiddenProc = createMockProc()
const heldProc = createMockProc()
for (const proc of [...bulkProcs, hiddenProc, heldProc]) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const bulkSpawns: { id: string }[] = []
for (const _proc of bulkProcs) {
bulkSpawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
const hiddenSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
for (const proc of bulkProcs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 8 * 1024 * 1024,
flushScheduled: false
})
const setHidden = getPtySetHiddenRendererPtyListener()
const setInterest = getPtySetDeliveryInterestListener()
setHidden(null, { id: hiddenSpawn.id, hidden: true })
setInterest(null, { id: hiddenSpawn.id, interested: true })
hiddenProc.emitData('drop-without-write')
heldProc.emitData('held')
setInterest(null, { id: hiddenSpawn.id, interested: false })
mainWindow.webContents.send.mockClear()
let reentered = false
mainWindow.webContents.send.mockImplementation(
(channel: string, payload: { id?: string }) => {
if (channel === 'pty:modelRestoreNeeded' && payload.id === hiddenSpawn.id && !reentered) {
reentered = true
bulkProcs[0]!.emitExit(0)
}
}
)
vi.advanceTimersByTime(2)
const exitIndex = mainWindow.webContents.send.mock.calls.findIndex(
(call) => call[0] === 'pty:exit'
)
expect(exitIndex).toBeGreaterThanOrEqual(0)
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(true)
vi.advanceTimersByTime(1)
expect(
mainWindow.webContents.send.mock.calls
.slice(exitIndex + 1)
.some(
(call) =>
call[0] === 'pty:data' &&
(call[1] as { id?: string } | undefined)?.id !== bulkSpawns[0]!.id
)
).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('does not reactivate globally blocked PTYs when exit releases no prior credit', async () => {
vi.useFakeTimers()
const bulkProcs = Array.from({ length: 16 }, () => createMockProc())
const finalProc = createMockProc()
const heldProc = createMockProc()
for (const proc of [...bulkProcs, finalProc, heldProc]) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
for (const _proc of bulkProcs) {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
}
const finalSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string; incarnationId: string }
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})
for (const proc of bulkProcs) {
proc.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 8 * 1024 * 1024,
flushScheduled: false
})
finalProc.emitData('final-tail')
heldProc.emitData('held')
vi.advanceTimersByTime(2)
expect(getPtyRendererDeliveryDebugSnapshot().flushScheduled).toBe(false)
const timerCountBeforeExit = vi.getTimerCount()
mainWindow.webContents.send.mockClear()
finalProc.emitExit(0)
expect(mainWindow.webContents.send.mock.calls).toEqual([
['pty:data', { id: finalSpawn.id, data: 'final-tail' }],
['pty:exit', { id: finalSpawn.id, code: 0, incarnationId: finalSpawn.incarnationId }]
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
rendererInFlightChars: 8 * 1024 * 1024,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(timerCountBeforeExit)
} finally {
vi.useRealTimers()
}
})
it('does not schedule a teardown-only flush for the last active blocked PTY', async () => {
vi.useFakeTimers()
const proc = createMockProc()
spawnMock.mockReturnValue(proc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
getPtySetActiveRendererPtyListener()(null, { id: spawn.id, active: true })
proc.emitData('x'.repeat(1200 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 80; index++) {
vi.advanceTimersByTime(1)
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
proc.emitExit(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
flushScheduled: false
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('prioritizes active PTY pending output during renderer backpressure', async () => {
vi.useFakeTimers()
const procs = Array.from({ length: 18 }, () => createMockProc())
for (const proc of procs) {
spawnMock.mockReturnValueOnce(proc.proc)
}
try {
registerPtyHandlers(mainWindow as never)
const spawns: { id: string }[] = []
for (const _proc of procs) {
spawns.push(
(await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
)
}
const ackData = getPtyAckDataListener()
const setActiveRendererPty = getPtySetActiveRendererPtyListener()
mainWindow.webContents.send.mockClear()
for (let index = 0; index < procs.length - 1; index++) {
procs[index]!.emitData('x'.repeat(600 * 1024))
}
vi.advanceTimersByTime(2)
for (let index = 0; index < 400; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512)
const activeIndex = procs.length - 1
procs[activeIndex]!.emitData('active-output')
setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true })
vi.advanceTimersByTime(2)
// Why: the fully gated arrival also emits a delivery resync probe, so count pty:data sends not raw webContents.send.
expect(getPtyDataSendCalls()).toHaveLength(513)
expect(getPtyDataSendCalls()[512]).toEqual([
'pty:data',
{
id: spawns[activeIndex]!.id,
data: 'active-output'
}
])
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
activeRendererPtyCount: 1,
pendingPtyCount: procs.length - 1,
rendererInFlightChars: 8 * 1024 * 1024 + 'active-output'.length
})
ackData(null, { id: spawns[0]!.id, charCount: 16 * 1024 })
} finally {
vi.useRealTimers()
}
})
it('lets active PTY output exceed its old background in-flight cap', async () => {
vi.useFakeTimers()
const activeProc = createMockProc()
spawnMock.mockReturnValue(activeProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const activeSpawn = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setActiveRendererPty = getPtySetActiveRendererPtyListener()
mainWindow.webContents.send.mockClear()
activeProc.emitData('x'.repeat(768 * 1024))
vi.advanceTimersByTime(2)
for (let index = 0; index < 31; index++) {
vi.advanceTimersByTime(1)
}
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 256 * 1024,
rendererInFlightChars: 512 * 1024,
maxRendererInFlightCharsByPty: 512 * 1024
})
setActiveRendererPty(null, { id: activeSpawn.id, active: true })
vi.advanceTimersByTime(1)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33)
expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', {
id: activeSpawn.id,
data: 'x'.repeat(16 * 1024)
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingChars: 240 * 1024,
rendererInFlightChars: 528 * 1024,
maxRendererInFlightCharsByPty: 528 * 1024
})
} finally {
vi.useRealTimers()
}
})
describe('hidden renderer delivery gate', () => {
it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => {
vi.useFakeTimers()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
const daemon = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'hidden output')
vi.advanceTimersByTime(50)
// Model ingestion still ran — only renderer delivery was dropped.
expect(runtime.onPtyData).toHaveBeenCalledWith(
result.id,
'hidden output',
expect.any(Number),
'hidden output'.length,
undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why out-of-band: an in-band empty pty:data chunk is ambiguous with chunks fully consumed by renderer OSC-9999 stripping.
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: result.id,
reason: 'hidden-drop',
markerSeq: 42
})
// Subsequent gated chunks drop silently — the marker is one-shot.
daemon.emitData(result.id, 'more hidden output')
vi.advanceTimersByTime(50)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 1,
hiddenDeliveryGatedVisiblePtyCount: 0,
hiddenDeliveryGatedActivePtyCount: 0,
hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length,
hiddenDeliveryDroppedChunks: 2,
pendingPtyCount: 0,
rendererInFlightChars: 0
})
} finally {
vi.useRealTimers()
}
})
it('surfaces the hidden-yet-visible contradiction in the snapshot and warns on drop', async () => {
// Why: field snapshot v1.4.124-rc.2.perf — aggregates couldn't tell if the visible pane was hidden-gated; overlap counter + warn makes it decisive.
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const daemon = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
const setVisible = getPtySetRendererPtyVisibleListener()
// The two visibility signals contradict: pane reports visible while the hidden-delivery gate still holds it.
setVisible(null, { id: result.id, visible: true })
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'starved visible output')
vi.advanceTimersByTime(50)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 1,
hiddenDeliveryGatedVisiblePtyCount: 1,
hiddenDeliveryDroppedChars: 'starved visible output'.length
})
expect(warnSpy).toHaveBeenCalledWith(
'[pty] hidden-delivery gate is dropping bytes for a visible/active pty',
expect.objectContaining({ id: result.id, visible: true })
)
// Unhiding resolves the contradiction.
setHidden(null, { id: result.id, hidden: false })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 0,
hiddenDeliveryGatedVisiblePtyCount: 0
})
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
it('embeds one-paste freeze diagnostics: per-pty table and breadcrumb history', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const daemon = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
const setVisible = getPtySetRendererPtyVisibleListener()
setVisible(null, { id: result.id, visible: true })
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'starved visible output')
vi.advanceTimersByTime(50)
const { diagnostics } = getPtyRendererDeliveryDebugSnapshot()
expect(diagnostics.appVersion).toBe('0.0.0-test')
expect(diagnostics.windowFocused).toBe(true)
expect(diagnostics.windowVisible).toBe(true)
const entry = diagnostics.perPty.find(
(candidate) => candidate.id === redactPtyIdForDiagnostics(result.id)
)
expect(entry).toMatchObject({
hidden: true,
visible: true,
inFlightChars: 0,
pendingChars: 0
})
// Why redaction is pinned: daemon session ids embed worktree paths, so the report must never carry the raw id.
expect(diagnostics.perPty.some((candidate) => candidate.id === result.id)).toBe(false)
const breadcrumbKinds = diagnostics.breadcrumbs.map((crumb) => crumb.kind)
expect(breadcrumbKinds).toContain('gate-mark')
expect(breadcrumbKinds).toContain('hidden-drop-visible')
} finally {
warnSpy.mockRestore()
vi.useRealTimers()
}
})
it('keeps the interactive bypass gated for hidden PTYs', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
const setHidden = getPtySetHiddenRendererPtyListener()
writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' })
setHidden(null, { id: spawnResult.id, hidden: true })
mainWindow.webContents.send.mockClear()
// A keystroke-sized redraw would take the immediate path when visible.
mockProc.emitData('\x1b[20;2Hredraw')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
} finally {
vi.useRealTimers()
}
})
it('suppresses the gate while renderer delivery interest is registered', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
const setInterest = getPtySetDeliveryInterestListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
setInterest(null, { id: spawnResult.id, interested: true })
mockProc.emitData('sidecar bytes')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'sidecar bytes'
})
setInterest(null, { id: spawnResult.id, interested: false })
mainWindow.webContents.send.mockClear()
mockProc.emitData('gated bytes')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
} finally {
vi.useRealTimers()
}
})
it('drops queued hidden data when interest ends before dispatcher readiness', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
const setInterest = getPtySetDeliveryInterestListener()
const setActive = getPtySetActiveRendererPtyListener()
getMainWindowWebContentsListener('did-start-loading')()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
setInterest(null, { id: spawnResult.id, interested: true })
mockProc.emitData('boot-window sidecar bytes')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
rendererPtyDispatcherReady: false,
ackGatedFlushSkipCount: 0
})
const timerCountBeforeNoops = vi.getTimerCount()
setHidden(null, { id: spawnResult.id, hidden: true })
setInterest(null, { id: spawnResult.id, interested: true })
setActive(null, { id: spawnResult.id, active: false })
expect(vi.getTimerCount()).toBe(timerCountBeforeNoops)
setInterest(null, { id: spawnResult.id, interested: false })
vi.advanceTimersByTime(0)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererPtyDispatcherReady: false,
ackGatedFlushSkipCount: 0
})
} finally {
vi.useRealTimers()
}
})
it.each([
['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }],
['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }]
])('keeps delivery when the %s kill switch is off', async (_name, settings) => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
mockProc.emitData('still delivered')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'still delivered'
})
} finally {
vi.useRealTimers()
}
})
it.each(['terminalHiddenDeliveryGate', 'terminalMainSideEffectAuthority'] as const)(
'reevaluates blocked hidden data when the live %s setting enables the derived gate',
async (settingName) => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
const settings = {
terminalHiddenDeliveryGate: true,
terminalMainSideEffectAuthority: true
}
settings[settingName] = false
try {
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
getMainWindowWebContentsListener('did-start-loading')()
getPtySetHiddenRendererPtyListener()(null, { id: spawnResult.id, hidden: true })
mainWindow.webContents.send.mockClear()
mockProc.emitData('blocked while gate disabled')
vi.advanceTimersByTime(2)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
rendererPtyDispatcherReady: false
})
settings[settingName] = true
getPtyAckDataListener()(null, { id: spawnResult.id, processedChars: 0 })
vi.advanceTimersByTime(0)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 0,
rendererPtyDispatcherReady: false
})
} finally {
vi.useRealTimers()
}
}
)
it('drops queued pending data when a PTY is marked hidden', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
mockProc.emitData('queued before hidden')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
setHidden(null, { id: spawnResult.id, hidden: true })
// The queued bytes are model-owned; only the restore marker goes out.
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 })
} finally {
vi.useRealTimers()
}
})
it('re-emits the restore marker on unhide and resumes delivery', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
mockProc.emitData('dropped while hidden')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why: a renderer reload can replace the view that latched restore-needed; unhide repeats the marker so the live view heals.
setHidden(null, { id: spawnResult.id, hidden: false })
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'unhide'
})
mockProc.emitData('visible again')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible again'
})
} finally {
vi.useRealTimers()
}
})
it('does not emit an unhide marker when nothing was dropped', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
setHidden(null, { id: spawnResult.id, hidden: false })
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('clears gate state on PTY exit', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
setHidden(null, { id: spawnResult.id, hidden: true })
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 1
})
mockProc.emitExit(0)
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 0,
deliveryInterestPtyCount: 0
})
} finally {
vi.useRealTimers()
}
})
it('keeps drop memory across a hidden remount so reveal still restores', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: spawnResult.id, hidden: true })
mockProc.emitData('dropped while hidden')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why: a hidden remount (tab move, parking handoff) re-marks without an unhide, so re-marking must NOT erase drop memory.
setHidden(null, { id: spawnResult.id, hidden: true })
setHidden(null, { id: spawnResult.id, hidden: false })
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'unhide'
})
} finally {
vi.useRealTimers()
}
})
it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => {
vi.useFakeTimers()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
const daemon = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never, runtime as never)
// Why daemon provider: survives reloads and keeps orphan-kill off this webContents, so 'did-finish-load' means gate reset only.
const reloadHandlers = mainWindow.webContents.on.mock.calls
.filter((call: unknown[]) => call[0] === 'did-finish-load')
.map((call: unknown[]) => call[1] as () => void)
expect(reloadHandlers).toHaveLength(1)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'dropped while hidden')
vi.advanceTimersByTime(50)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
// Renderer reload: hidden marks die with the old renderer, but dropped bytes were never restored — memory must survive.
reloadHandlers[0]()
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
hiddenDeliveryGatedPtyCount: 0
})
// The reloaded pane's first sync re-marks hidden, then reveals.
setHidden(null, { id: result.id, hidden: true })
setHidden(null, { id: result.id, hidden: false })
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', {
id: result.id,
reason: 'unhide',
markerSeq: 42
})
} finally {
vi.useRealTimers()
}
})
it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => {
vi.useFakeTimers()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
const daemon = installObservableDaemonTestProvider()
try {
registerPtyHandlers(mainWindow as never, runtime as never)
const reloadHandlers = mainWindow.webContents.on.mock.calls
.filter((call: unknown[]) => call[0] === 'did-finish-load')
.map((call: unknown[]) => call[1] as () => void)
expect(reloadHandlers).toHaveLength(1)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session'
})) as { id: string }
const setHidden = getPtySetHiddenRendererPtyListener()
const setInterest = getPtySetDeliveryInterestListener()
mainWindow.webContents.send.mockClear()
// A sidecar holds interest, so hidden bytes still flow.
setInterest(null, { id: result.id, interested: true })
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'sidecar bytes')
vi.advanceTimersByTime(50)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith(
'pty:data',
expect.objectContaining({ id: result.id, data: 'sidecar bytes' })
)
// Why: the renderer reload killed the sidecar's ref count without a release IPC — the leaked hold must not force-feed the PTY forever.
reloadHandlers[0]()
mainWindow.webContents.send.mockClear()
setHidden(null, { id: result.id, hidden: true })
daemon.emitData(result.id, 'gated after reload')
vi.advanceTimersByTime(50)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: result.id,
reason: 'hidden-drop',
markerSeq: 42
})
} finally {
vi.useRealTimers()
}
})
})
describe('hidden-at-spawn mark (initiallyHidden)', () => {
// terminal-query-authority.md §races: hidden-at-spawn marks the PTY before byte one, closing the spawn-time DA1-loss window.
function createRuntimeMock() {
return {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
}
it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => {
vi.useFakeTimers()
const runtime = createRuntimeMock()
const daemon = installObservableDaemonTestProvider()
const spawnGate = makeDeferred()
daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => {
await spawnGate.promise
return { id: options.sessionId ?? 'daemon-pty' }
})
try {
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
}) as Promise<{ id: string }>
// Let the handler run up to the awaited provider.spawn.
await Promise.resolve()
mainWindow.webContents.send.mockClear()
// Daemon PTYs can emit prompt bytes before spawn() resolves, so the pre-spawn mark must already gate them.
expect(isHiddenRendererPty('daemon-session')).toBe(true)
daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c')
vi.advanceTimersByTime(50)
expect(runtime.onPtyData).toHaveBeenCalledWith(
'daemon-session',
'pre-spawn prompt\x1b[c',
expect.any(Number),
'pre-spawn prompt\x1b[c'.length,
undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: 'daemon-session',
reason: 'hidden-drop',
markerSeq: 42
})
spawnGate.resolve()
const result = await spawnPromise
expect(isHiddenRendererPty(result.id)).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('clears the pre-spawn hidden mark when the spawn fails', async () => {
const daemon = installObservableDaemonTestProvider()
daemon.spawn.mockRejectedValue(new Error('spawn exploded'))
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
})
).rejects.toThrow('spawn exploded')
// A later visible attach reusing this session id must not start gated.
expect(isHiddenRendererPty('daemon-session')).toBe(false)
})
it('marks local PTYs hidden after spawn, before their first data task', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
initiallyHidden: true
})) as { id: string }
mainWindow.webContents.send.mockClear()
expect(isHiddenRendererPty(spawnResult.id)).toBe(true)
mockProc.emitData('first chunk')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
} finally {
vi.useRealTimers()
}
})
it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
expect(isHiddenRendererPty(spawnResult.id)).toBe(false)
mockProc.emitData('visible output')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible output'
})
} finally {
vi.useRealTimers()
}
})
it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => {
// End-to-end through a REAL runtime: spawn-marked → first chunk dropped → emulator parses query → replies; main answers, the renderer never saw the bytes.
const daemon = installObservableDaemonTestProvider()
const runtime = new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
terminalMainSideEffectAuthority: true,
terminalHiddenDeliveryGate: true,
terminalModelQueryAuthority: true
})
} as never)
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
})) as { id: string }
daemon.emitData(result.id, '\x1b[c')
// Settle the per-PTY emulator writeChain (and the reply it forwards).
await runtime.serializeMainTerminalBuffer(result.id)
expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c')
})
})
it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
// 3 MB in one entry: the scrollback-scaled cap (2 MB default) drops to O(1) memory; one restore marker fires, droppedOutput routes to the snapshot repaint.
mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024))
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'pending-cap'
})
// A second overflow before the entry drains must not re-mark.
mockProc.emitData('z'.repeat(64 * 1024))
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: spawnResult.id,
data: '',
droppedOutput: true
})
} finally {
vi.useRealTimers()
}
})
it.each([
['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }],
['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }]
])(
'keeps the pending cap active without a restore marker when the %s kill switch is off',
async (_name, settings) => {
// Why: the pending cap ships independently of the gate (#7150) — droppedOutput repaint survives kill switches; only the restore marker is switch-scoped.
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
mockProc.emitData('x'.repeat(3 * 1024 * 1024))
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith(
'pty:modelRestoreNeeded',
expect.anything()
)
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: spawnResult.id,
data: '',
droppedOutput: true
})
} finally {
vi.useRealTimers()
}
}
)
it('batches stale PTY output after the interactive window expires', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
const writeListener = getPtyWriteListener()
writeListener(mainWindowIpcEvent, {
id: spawnResult.id,
data: 'a'
})
vi.advanceTimersByTime(101)
mainWindow.webContents.send.mockClear()
mockProc.emitData('stale redraw')
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'stale redraw'
})
} finally {
vi.useRealTimers()
}
})
posixOnlyIt('falls back to a system shell when SHELL points to a missing binary', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
existsSyncMock.mockImplementation(
(targetPath: string) => targetPath !== '/opt/homebrew/bin/bash'
)
try {
process.env.SHELL = '/opt/homebrew/bin/bash'
registerPtyHandlers(mainWindow as never)
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp'
})
expect(result).toEqual({
id: expect.any(String),
pid: 12345,
incarnationId: expect.any(String)
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({ cwd: '/tmp' })
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
posixOnlyIt('falls back when SHELL points to a non-executable binary', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
accessSyncMock.mockImplementation((targetPath: string) => {
if (targetPath === '/opt/homebrew/bin/bash') {
throw new Error('permission denied')
}
})
try {
process.env.SHELL = '/opt/homebrew/bin/bash'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp'
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({
cwd: '/tmp',
env: expect.objectContaining({
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config',
ORCA_SHELL_READY_MARKER: '0',
ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh'
})
})
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Shell "/opt/homebrew/bin/bash" is not executable')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('acknowledges pty writes only for owned PTYs', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: '\x03'
})
).toBe(true)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x03')
expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: 'missing-pty-for-write-ack',
data: '\x03'
})
).toBe(false)
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
})
it('asks the renderer to remount when the provider rejects a stale daemon write', async () => {
const write = vi.fn(() => {
throw new PtyWriteUnavailableError('daemon generation lost')
})
installDaemonTestProvider({ write })
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
mainWindow.webContents.send.mockClear()
getPtyWriteListener()(mainWindowIpcEvent, { id: result.id, data: 'x' })
expect(write).toHaveBeenCalledWith(result.id, 'x')
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', {
id: result.id
})
})
it('rejects malformed and cross-window pty write IPC before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const write = getPtyWriteListener() as (event: unknown, args: unknown) => void
const writeAccepted = handlers.get('pty:writeAccepted')! as (
event: unknown,
args: unknown
) => unknown
write(mainWindowIpcEvent, null)
write(mainWindowIpcEvent, { id: '', data: 'x' })
write(mainWindowIpcEvent, { id: result.id, data: 1 })
write(foreignWindowIpcEvent, { id: result.id, data: 'x' })
expect(writeAccepted(mainWindowIpcEvent, null)).toBe(false)
expect(writeAccepted(mainWindowIpcEvent, { id: '', data: 'x' })).toBe(false)
expect(writeAccepted(mainWindowIpcEvent, { id: result.id, data: 1 })).toBe(false)
expect(writeAccepted(foreignWindowIpcEvent, { id: result.id, data: 'x' })).toBe(false)
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('silently drops writes to a live PTY after ownership loss until pty:listSessions rebuilds it (frozen-terminal repro)', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const write = getPtyWriteListener()
write(mainWindowIpcEvent, { id: result.id, data: 'alive' })
expect(mockProc.proc.write).toHaveBeenCalledWith('alive')
// Field failure (Discord #performance / #2836): a pane can render with a ptyId whose ownership is gone while the PTY lives, so keystrokes vanish silently.
deletePtyOwnership(result.id)
write(mainWindowIpcEvent, { id: result.id, data: 'dropped' })
expect(mockProc.proc.write).not.toHaveBeenCalledWith('dropped')
// pty:listSessions rebuilds ownership from provider sessions — the revival lever the frozen-pane e2e probes depend on.
await handlers.get('pty:listSessions')!(null, undefined)
write(mainWindowIpcEvent, { id: result.id, data: 'revived' })
expect(mockProc.proc.write).toHaveBeenCalledWith('revived')
})
it('chunks large acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('')
await expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id: result.id, data: text })
).resolves.toBe(true)
expect(mockProc.proc.write).toHaveBeenNthCalledWith(
1,
'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
)
expect(mockProc.proc.write).toHaveBeenNthCalledWith(2, 'tail')
})
it('yields while validating accepted large acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
vi.useFakeTimers()
const writeResult = handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: text
})
expect(writeResult).toBeInstanceOf(Promise)
expect(mockProc.proc.write).not.toHaveBeenCalled()
await vi.runAllTimersAsync()
await expect(writeResult).resolves.toBe(true)
expect(mockProc.proc.write.mock.calls.map(([chunk]) => chunk).join('')).toBe(text)
})
it('rejects oversized acknowledged pty writes before provider writes', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(
handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, {
id: result.id,
data: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)
})
).toBe(false)
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: 'pty-restored',
isReattach: true,
providerSequence: { value: 900, generation: 'continued' as const }
})),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getPtyOutputSequence: vi.fn().mockReturnValue(7),
synchronizePtyOutputSequenceFromProvider: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith(
'pty-restored',
{ value: 900, generation: 'continued' },
7
)
})
it('seeds cold restore at recovered dimensions with a legacy dimensionless fallback', async () => {
const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }]
const coldRestore = {
scrollback: 'restored history\r\n',
cwd: '/projects/restored',
cols: 132,
rows: 43,
oscLinks
}
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-cold-restore', coldRestore })
.mockResolvedValueOnce({
id: 'pty-legacy-cold-restore',
coldRestore: { scrollback: 'legacy history\r\n', cwd: '/projects/legacy' }
})
setLocalPtyProvider({
spawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'handle-cold-restore'),
registerPreAllocatedHandleForPty: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
1,
'pty-cold-restore',
'restored history\r\n',
{ cols: 132, rows: 43 },
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
2,
'pty-legacy-cold-restore',
'legacy history\r\n',
undefined,
{ cwd: '/projects/legacy', oscLinks: undefined, preferProviderIfExisting: true }
)
})
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: 'tab-1:0' }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: stablePaneKey
})
)
expect(registerPaneKeyAliasMock).toHaveBeenCalledWith(
'tab-1:0',
stablePaneKey,
expect.any(String),
expect.any(Number),
{ authorityVerified: true }
)
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled()
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: stablePaneKey
})
)
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: makePaneKey('tab-2', leafId) }
})
expect(registerPtyMock).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: null
})
)
})
it('does not let an old PTY teardown clear a newer pane-key owner', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
const first = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
const second = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id)
clearAgentHookPaneStateMock.mockClear()
clearProviderPtyState(first.id)
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(second.id)
expect(clearAgentHookPaneStateMock).not.toHaveBeenCalledWith(stablePaneKey)
clearProviderPtyState(second.id)
expect(getPtyIdForPaneKey(stablePaneKey)).toBeUndefined()
expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey)
})
it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
const stablePaneKey = makePaneKey('tab-1', leafId)
const current = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: stablePaneKey }
})) as { id: string }
expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id)
clearPaneKeyAliasesForPtyMock.mockClear()
clearProviderPtyState('old-pty-without-forward-pane-key')
const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find(
([ptyId]) => ptyId === 'old-pty-without-forward-pane-key'
)?.[1]
expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false)
})
posixOnlyIt('prefers args.env.SHELL and normalizes the child env after fallback', async () => {
const originalShell = process.env.SHELL
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
existsSyncMock.mockImplementation(
(targetPath: string) => targetPath !== '/opt/homebrew/bin/bash'
)
try {
process.env.SHELL = '/bin/bash'
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
env: { SHELL: '/opt/homebrew/bin/bash' }
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'/bin/zsh',
['-l'],
expect.objectContaining({
cwd: '/tmp',
env: expect.objectContaining({
SHELL: '/bin/zsh',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config',
ORCA_SHELL_READY_MARKER: '0',
ZDOTDIR: '/tmp/orca-user-data/shell-ready/zsh'
})
})
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Primary shell "/opt/homebrew/bin/bash" failed')
)
} finally {
warnSpy.mockRestore()
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
}
})
it('cleans up provider-specific PTY overlays when a PTY is killed', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
// Simulate node-pty behavior: kill triggers onExit callback
exitCb?.({ exitCode: -1 })
}),
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
await handlers.get('pty:kill')!(null, { id: spawnResult.id })
expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
})
it('retains PTY listeners until physical exit after manual kill IPC', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
let exitCb: ((info: { exitCode: number }) => void) | undefined
// Why: hold a stable ref to the kill spy — destroyPtyProcess reassigns proc.kill to a no-op (docs/fix-pty-fd-leak.md), so reading proc.kill.mock later would crash.
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return onExitDisposable
}),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const killPromise = handlers.get('pty:kill')!(null, { id: spawnResult.id }) as Promise<void>
expect(killSpy).toHaveBeenCalledTimes(1)
expect(onDataDisposable.dispose).not.toHaveBeenCalled()
expect(onExitDisposable.dispose).not.toHaveBeenCalled()
exitCb?.({ exitCode: -1 })
await killPromise
expect(onDataDisposable.dispose).toHaveBeenCalledTimes(1)
expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1)
})
it('retains PTY listeners until physical exit after runtime controller kill', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
let exitCb: ((info: { exitCode: number }) => void) | undefined
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return onExitDisposable
}),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
const runtimeController = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
}
expect(runtimeController.kill(spawnResult.id)).toBe(true)
await vi.waitFor(() => expect(killSpy).toHaveBeenCalledTimes(1))
expect(onDataDisposable.dispose).not.toHaveBeenCalled()
expect(onExitDisposable.dispose).not.toHaveBeenCalled()
exitCb?.({ exitCode: -1 })
await vi.waitFor(() => expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1))
expect(onDataDisposable.dispose).toHaveBeenCalledTimes(1)
})
it('retains the PTY exit listener through did-finish-load orphan cleanup', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
let exitCb: ((info: { exitCode: number }) => void) | undefined
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return onExitDisposable
}),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never, runtime as never)
// Why both: a reload fires the gate reset AND the orphan cleanup, so invoke every registered listener like a real did-finish-load.
const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler as () => void)
expect(didFinishLoadHandlers.length).toBeGreaterThan(0)
const didFinishLoad = (): void => {
for (const handler of didFinishLoadHandlers) {
handler()
}
}
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
// First load after spawn only advances generation; the second sees this PTY as from a prior load and kills it as orphaned.
didFinishLoad()
didFinishLoad()
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killSpy.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose).not.toHaveBeenCalled()
exitCb?.({ exitCode: -1 })
expect(onExitDisposable.dispose).toHaveBeenCalledTimes(1)
})
it('removes the previous orphan-cleanup listener from its original webContents', () => {
const firstWindow = {
isDestroyed: () => false,
isFocused: () => true,
isVisible: () => true,
isMinimized: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
const secondWindow = {
isDestroyed: () => false,
isFocused: () => true,
isVisible: () => true,
isMinimized: () => false,
webContents: {
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
registerPtyHandlers(firstWindow as never)
// Two listeners on the first (LocalPtyProvider) window: the renderer-gate reset and the orphan cleanup.
const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter(
([eventName]) => eventName === 'did-finish-load'
)
expect(firstWindowLoadHandlers).toHaveLength(2)
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
registerPtyHandlers(secondWindow as never)
// Every first-window load listener was detached from its webContents.
for (const [, handler] of firstWindowLoadHandlers) {
expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith(
'did-finish-load',
handler
)
}
// The non-Local provider keeps orphan cleanup off the second window — only the renderer-gate reset listener remains.
expect(
secondWindow.webContents.on.mock.calls.filter(
([eventName]) => eventName === 'did-finish-load'
)
).toHaveLength(1)
})
// Why (#5787): a recovery reload re-fires did-finish-load; suppress the orphan sweep so live LOCAL PTYs survive until session restore re-adopts them.
it('does not sweep local PTYs during a recovery reload', async () => {
const killSpy = vi.fn()
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
const isRecoveryReloadInFlight = vi.fn(() => true)
const markClaudePtyExitedSpy = vi.spyOn(livePtyGate, 'markClaudePtyExited')
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ isRecoveryReloadInFlight }
)
// Fire both did-finish-load listeners as a real reload does, else the suppression assertion passes vacuously without reaching the sweep.
const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler as () => void)
expect(didFinishLoadHandlers.length).toBeGreaterThan(0)
const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler())
const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as {
id: string
incarnationId: string
}
// Without the guard the second load would sweep this PTY as a prior-generation orphan; under recovery-in-flight neither load may touch it.
didFinishLoad()
didFinishLoad()
expect(killSpy).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(markClaudePtyExitedSpy).not.toHaveBeenCalled()
const listed = await getLocalPtyProvider().listProcesses()
expect(listed.some((info) => info.id === spawnResult.id)).toBe(true)
markClaudePtyExitedSpy.mockRestore()
})
// Why: guard against over-suppression — with no recovery reload in flight the sweep MUST still reclaim genuinely orphaned local PTYs.
it('still sweeps orphaned local PTYs when no recovery reload is in flight', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
const killSpy = vi.fn(() => {
queueMicrotask(() => exitCb?.({ exitCode: -1 }))
})
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: killSpy,
process: 'zsh',
pid: 12345
}
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
const isRecoveryReloadInFlight = vi.fn(() => false)
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ isRecoveryReloadInFlight }
)
// Fire both did-finish-load listeners (gate reset + orphan sweep) as a real reload does.
const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler as () => void)
const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler())
const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as {
id: string
incarnationId: string
}
// First load only advances generation; the second sees this PTY as a prior-load orphan — with the flag false the guard must NOT suppress the sweep.
didFinishLoad()
didFinishLoad()
await Promise.resolve()
expect(killSpy).toHaveBeenCalled()
expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId)
const listed = await getLocalPtyProvider().listProcesses()
expect(listed.some((info) => info.id === spawnResult.id)).toBe(false)
})
// Why (#5787): two PTYs in different load generations must BOTH survive a recovery reload — even the older one a normal sweep would reclaim.
it('keeps local PTYs from different generations alive across recovery reloads', async () => {
const killSpyA = vi.fn()
const killSpyB = vi.fn()
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
const isRecoveryReloadInFlight = vi.fn(() => true)
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ isRecoveryReloadInFlight }
)
// Fire ALL did-finish-load listeners (gate reset + orphan sweep) as a real reload does; the sweep listener is under test.
const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler as () => void)
const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler())
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: killSpyA,
process: 'zsh',
pid: 111
})
const ptyA = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string }
// Advance the generation without sweeping (recovery-in-flight), then spawn a second PTY so the two live in different generations.
didFinishLoad()
spawnMock.mockReturnValue({
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
resize: vi.fn(),
kill: killSpyB,
process: 'zsh',
pid: 222
})
const ptyB = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string }
didFinishLoad()
expect(killSpyA).not.toHaveBeenCalled()
expect(killSpyB).not.toHaveBeenCalled()
const ids = (await getLocalPtyProvider().listProcesses()).map((info) => info.id)
expect(ids).toContain(ptyA.id)
expect(ids).toContain(ptyB.id)
})
it('retains PTY state when kill fails until physical exit arrives', async () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
const proc = {
onData: vi.fn(() => makeDisposable()),
onExit: vi.fn((cb: (info: { exitCode: number }) => void) => {
exitCb = cb
return makeDisposable()
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
throw new Error('already dead')
}),
process: 'zsh',
pid: 12345
}
spawnMock.mockReturnValue(proc)
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
await expect(handlers.get('pty:kill')!(null, { id: spawnResult.id })).rejects.toThrow(
'already dead'
)
expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).toContain(
spawnResult.id
)
expect(openCodeClearPtyMock).not.toHaveBeenCalled()
expect(piClearPtyMock).not.toHaveBeenCalled()
exitCb?.({ exitCode: -1 })
expect((await getLocalPtyProvider().listProcesses()).map(({ id }) => id)).not.toContain(
spawnResult.id
)
expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
})
describe('agent_started telemetry', () => {
// Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event.
it('emits agent_started after a successful spawn when telemetry is supplied', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
expect(trackMock).toHaveBeenCalledWith('agent_started', {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
})
})
it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(trackMock).not.toHaveBeenCalled()
})
it('drops the event when any telemetry field is outside its closed enum', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
telemetry: {
agent_kind: 'claude-code',
launch_source: 'not_a_real_surface',
request_kind: 'new'
}
})
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
it('does not emit agent_started when provider.spawn throws', async () => {
// Why: agent_started fires only on confirmed launch — inject a throwing provider to hit the catch path with no race against the real LocalPtyProvider.
setLocalPtyProvider({
spawn: vi.fn(async () => {
throw new Error('spawn boom')
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
classifyErrorMock.mockReturnValue({ error_class: 'unknown' })
handlers.clear()
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'claude',
telemetry: {
agent_kind: 'claude-code',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
})
).rejects.toThrow(/spawn boom/)
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
})
describe('serializeBuffer dispatch', () => {
type SerializeListener = (
_event: unknown,
args: {
requestId?: string
snapshot?: { data?: unknown; cols?: unknown; rows?: unknown; lastTitle?: unknown } | null
}
) => void
type SerializeController = {
serializeBuffer: (
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
}
function setup(): { listener: SerializeListener; controller: SerializeController } {
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const onCall = onMock.mock.calls.find(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
if (!onCall) {
throw new Error('expected pty:serializeBuffer:response listener registration')
}
const listener = onCall[1] as SerializeListener
const controller = runtime.setPtyController.mock.calls[0]?.[0] as SerializeController
return { listener, controller }
}
function getSentRequestIds(): string[] {
return mainWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'pty:serializeBuffer:request')
.map((call: unknown[]) => (call[1] as { requestId: string }).requestId)
}
it('registers exactly one persistent listener regardless of concurrent in-flight requests', async () => {
const { listener, controller } = setup()
const inflight = [
controller.serializeBuffer('pty-1'),
controller.serializeBuffer('pty-2'),
controller.serializeBuffer('pty-3'),
controller.serializeBuffer('pty-4'),
controller.serializeBuffer('pty-5'),
controller.serializeBuffer('pty-6'),
controller.serializeBuffer('pty-7'),
controller.serializeBuffer('pty-8'),
controller.serializeBuffer('pty-9'),
controller.serializeBuffer('pty-10'),
controller.serializeBuffer('pty-11'),
controller.serializeBuffer('pty-12')
]
// Why: the bug registered one listener per request, so 12 concurrent calls would trip Node's MaxListeners.
const responseChannelRegistrations = onMock.mock.calls.filter(
(call: unknown[]) => call[0] === 'pty:serializeBuffer:response'
)
expect(responseChannelRegistrations.length).toBe(1)
// Drain the in-flight requests so the test doesn't leak timers.
for (const requestId of getSentRequestIds()) {
listener(null, { requestId, snapshot: null })
}
await Promise.all(inflight)
})
it('routes each response to the originating request via requestId', async () => {
const { listener, controller } = setup()
const a = controller.serializeBuffer('pty-a')
const b = controller.serializeBuffer('pty-b')
const ids = getSentRequestIds()
const requestIdA = ids[0]
const requestIdB = ids[1]
listener(null, {
requestId: requestIdB,
snapshot: { data: 'B-data', cols: 80, rows: 24 }
})
listener(null, {
requestId: requestIdA,
snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' }
})
await expect(b).resolves.toEqual({ data: 'B-data', cols: 80, rows: 24 })
await expect(a).resolves.toEqual({
data: 'A-data',
cols: 100,
rows: 30,
lastTitle: 'A-title'
})
})
it('ignores responses with unknown requestId without affecting pending requests', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-1')
const realRequestId = getSentRequestIds()[0]
listener(null, {
requestId: 'not-a-real-id',
snapshot: { data: 'irrelevant', cols: 1, rows: 1 }
})
listener(null, { requestId: undefined, snapshot: null })
let resolved = false
void pending.then(() => {
resolved = true
})
await new Promise((r) => setTimeout(r, 0))
expect(resolved).toBe(false)
listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } })
await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 })
})
it('resolves to null and removes the entry when the 750ms timeout fires', async () => {
vi.useFakeTimers()
try {
const { controller } = setup()
const pending = controller.serializeBuffer('pty-stuck')
vi.advanceTimersByTime(750)
await expect(pending).resolves.toBeNull()
} finally {
vi.useRealTimers()
}
})
it('resolves to null when the response snapshot is malformed', async () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-bad')
const requestId = getSentRequestIds()[0]
listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } })
await expect(pending).resolves.toBeNull()
})
})
describe('provider buffer snapshot dispatch', () => {
it('exposes daemon history when no renderer pane is mounted', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise<unknown>
}
await expect(
controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 })
).resolves.toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', {
scrollbackRows: 5000
})
})
})
describe('main buffer snapshot dispatch', () => {
it('returns a hidden-output recovery snapshot with clamped scrollback', async () => {
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 42),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'pty-1',
opts: { scrollbackRows: 999_999 }
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 50_000
})
// Why pendingDeliveryStartSeq === seq: pending delivery queue is empty, so low-seq live chunks must not be dropped against the snapshot baseline.
expect(result).toEqual({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 42,
pendingDeliveryStartSeq: 42,
source: 'headless'
})
})
it('uses the complete provider model after daemon stream thinning', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'complete daemon scrollback\r\n',
cols: 100,
rows: 30,
seq: 900,
source: 'headless'
})
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 640),
notePtyDataGap: vi.fn(),
onPtyExit: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'kept tail only\r\n',
cols: 100,
rows: 30,
seq: 640,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
provider.emitDataGap('daemon-pty', 512)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'daemon-pty',
opts: { scrollbackRows: 5000 }
})
expect(runtime.notePtyDataGap).toHaveBeenCalledWith('daemon-pty', 512)
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', {
scrollbackRows: 5000
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled()
expect(result).toEqual({
data: 'complete daemon scrollback\r\n',
cols: 100,
rows: 30,
seq: 900,
source: 'headless',
// Bytes between main's absolute seq and the daemon snapshot may still be queued on the stream socket and must dedupe on arrival.
pendingDeliveryStartSeq: 640
})
provider.emitExit('daemon-pty')
})
it("never paints main's incomplete tail when a required provider snapshot is unavailable", async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue(null)
const runtime = {
setPtyController: vi.fn(),
getPtyOutputSequence: vi.fn(() => 640),
notePtyDataGap: vi.fn(),
onPtyExit: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'kept tail only\r\n',
cols: 100,
rows: 30,
seq: 640,
source: 'headless'
})
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
provider.emitDataGap('daemon-pty', 512)
const result = await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: 'daemon-pty',
opts: { scrollbackRows: 5000 }
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', {
scrollbackRows: 5000
})
expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled()
expect(result).toBeNull()
provider.emitExit('daemon-pty')
})
it('reports where the undelivered pending backlog starts alongside the snapshot', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
preAllocateHandleForPty: vi.fn(() => null),
getPtyOutputSequence: vi.fn(() => 2_472),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 100,
rows: 30,
seq: 2_472,
source: 'headless'
})
}
try {
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
// Starved pending entry: bytes ingested but not yet flushed to the renderer — they can still arrive after the snapshot.
mockProc.emitData('frame-bytes')
const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, {
id: spawnResult.id
})) as { pendingDeliveryStartSeq?: number }
expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length)
} finally {
vi.useRealTimers()
}
})
})
})