mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
971 lines
34 KiB
TypeScript
971 lines
34 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { Session } from './session'
|
|
import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller'
|
|
import { HeadlessEmulator } from './headless-emulator'
|
|
import type { SessionState, ShellReadyState } from './types'
|
|
import type { TuiAgent } from '../../shared/tui-agent'
|
|
|
|
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('../pty-descendant-termination', () => ({
|
|
killWithDescendantSweep: killWithDescendantSweepMock
|
|
}))
|
|
|
|
// Stub the subprocess — Session talks to it via an interface, not child_process directly.
|
|
function createMockSubprocess() {
|
|
const written: string[] = []
|
|
const signals: string[] = []
|
|
let onData: ((data: string) => void) | null = null
|
|
let onExit: ((code: number) => void) | null = null
|
|
let killed = false
|
|
let clearCalls = 0
|
|
let pid = 12345
|
|
let pauseCalls = 0
|
|
let resumeCalls = 0
|
|
|
|
return {
|
|
written,
|
|
signals,
|
|
get killed() {
|
|
return killed
|
|
},
|
|
get pid() {
|
|
return pid
|
|
},
|
|
get pauseCalls() {
|
|
return pauseCalls
|
|
},
|
|
get resumeCalls() {
|
|
return resumeCalls
|
|
},
|
|
foregroundProcess: null as string | null,
|
|
getForegroundProcess(): string | null {
|
|
return this.foregroundProcess
|
|
},
|
|
confirmShellForeground: vi.fn(async () => true),
|
|
write(data: string) {
|
|
written.push(data)
|
|
},
|
|
resize(_cols: number, _rows: number) {},
|
|
pause() {
|
|
pauseCalls++
|
|
},
|
|
resume() {
|
|
resumeCalls++
|
|
},
|
|
get clearCalls() {
|
|
return clearCalls
|
|
},
|
|
clear() {
|
|
clearCalls++
|
|
},
|
|
kill() {
|
|
killed = true
|
|
// Simulate async exit
|
|
setTimeout(() => onExit?.(0), 5)
|
|
},
|
|
terminateOwnedTree: () => 'terminated' as const,
|
|
forceKill() {
|
|
killed = true
|
|
},
|
|
signal(sig: string) {
|
|
signals.push(sig)
|
|
},
|
|
onData(cb: (data: string) => void) {
|
|
onData = cb
|
|
},
|
|
onExit(cb: (code: number) => void) {
|
|
onExit = cb
|
|
},
|
|
dispose() {},
|
|
// Helpers for tests to simulate subprocess events
|
|
simulateData(data: string) {
|
|
onData?.(data)
|
|
},
|
|
simulateExit(code: number) {
|
|
onExit?.(code)
|
|
}
|
|
}
|
|
}
|
|
|
|
type MockSubprocess = ReturnType<typeof createMockSubprocess>
|
|
|
|
describe('Session', () => {
|
|
let session: Session
|
|
let subprocess: MockSubprocess
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
subprocess = createMockSubprocess()
|
|
killWithDescendantSweepMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
session?.dispose()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
function createSession(opts?: {
|
|
shellReadySupported?: boolean
|
|
shellReadyTimeoutMs?: number
|
|
cols?: number
|
|
rows?: number
|
|
launchAgent?: TuiAgent
|
|
startupIngress?: {
|
|
colors: { foreground: string; background: string }
|
|
deadlineMs: number
|
|
}
|
|
ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl'
|
|
wslDistro?: string
|
|
reportReadinessEvent?: (event: string, details: Record<string, unknown>) => void
|
|
historySeedChunks?: readonly string[]
|
|
}): Session {
|
|
session = new Session({
|
|
sessionId: 'test-session',
|
|
...(opts?.reportReadinessEvent ? { reportReadinessEvent: opts.reportReadinessEvent } : {}),
|
|
cols: opts?.cols ?? 80,
|
|
rows: opts?.rows ?? 24,
|
|
...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
|
wslDistro: opts?.wslDistro,
|
|
subprocess,
|
|
historySeedChunks: opts?.historySeedChunks,
|
|
...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
|
|
shellReadySupported: opts?.shellReadySupported ?? false,
|
|
...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}),
|
|
...(opts?.shellReadyTimeoutMs !== undefined
|
|
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
|
|
: {})
|
|
})
|
|
return session
|
|
}
|
|
|
|
describe('state machine', () => {
|
|
it('starts in running state when shell readiness is not supported', () => {
|
|
createSession({ shellReadySupported: false })
|
|
expect(session.state).toBe('running' satisfies SessionState)
|
|
expect(session.shellState).toBe('unsupported' satisfies ShellReadyState)
|
|
})
|
|
|
|
it('starts in running state with pending shell when readiness is supported', () => {
|
|
createSession({ shellReadySupported: true })
|
|
expect(session.state).toBe('running')
|
|
expect(session.shellState).toBe('pending' satisfies ShellReadyState)
|
|
})
|
|
|
|
it('transitions to exited when subprocess exits', () => {
|
|
createSession()
|
|
subprocess.simulateExit(0)
|
|
expect(session.state).toBe('exited' satisfies SessionState)
|
|
expect(session.isAlive).toBe(false)
|
|
})
|
|
|
|
it('tracks exit code', () => {
|
|
createSession()
|
|
subprocess.simulateExit(42)
|
|
expect(session.exitCode).toBe(42)
|
|
})
|
|
})
|
|
|
|
describe('data flow', () => {
|
|
it('captures and strips the complete WSL shell anchor from live output', () => {
|
|
createSession({ wslDistro: 'Ubuntu' })
|
|
const marker =
|
|
'\x1b]777;orca-shell-start:v2:Ubuntu:01234567-89ab-cdef-0123-456789abcdef:123:456:/dev/pts/8\x07'
|
|
const received: string[] = []
|
|
session.attachClient({ onData: (data) => received.push(data), onExit: () => {} })
|
|
subprocess.simulateData(`before${marker}after`)
|
|
expect(received.join('')).toContain('beforeafter')
|
|
expect(received.join('')).not.toContain('orca-shell-start')
|
|
expect(session.getWslShellAnchor()).toEqual({
|
|
distro: 'Ubuntu',
|
|
bootId: '01234567-89ab-cdef-0123-456789abcdef',
|
|
shellPid: 123,
|
|
shellStartTime: 456,
|
|
tty: '/dev/pts/8'
|
|
})
|
|
})
|
|
|
|
it('drains a partial WSL identity marker when the subprocess exits', () => {
|
|
createSession({ wslDistro: 'Ubuntu' })
|
|
const received: string[] = []
|
|
session.attachClient({ onData: (data) => received.push(data), onExit: () => {} })
|
|
const partial = '\x1b]777;orca-shell-start:v2:Ubuntu:01234567'
|
|
|
|
subprocess.simulateData(partial)
|
|
subprocess.simulateExit(0)
|
|
|
|
expect(received.join('')).toContain(partial)
|
|
})
|
|
|
|
it('does not confirm shell ownership from historical replay bytes', () => {
|
|
createSession({
|
|
historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker']
|
|
})
|
|
|
|
expect(subprocess.confirmShellForeground).not.toHaveBeenCalled()
|
|
expect(session.getSnapshot()?.terminalOwner).toBeUndefined()
|
|
})
|
|
|
|
it('answers concurrent runtime confirmations from one episode inspection', async () => {
|
|
let resolveConfirmation: ((confirmed: boolean) => void) | undefined
|
|
subprocess.confirmShellForeground.mockImplementation(
|
|
() => new Promise((resolve) => void (resolveConfirmation = resolve))
|
|
)
|
|
createSession()
|
|
|
|
// Why no inspection without a candidate: the RPC reads the barrier's
|
|
// settled verdict; it must never mint proof the byte stream didn't ask for.
|
|
await expect(session.confirmShellForeground()).resolves.toBe(false)
|
|
expect(subprocess.confirmShellForeground).not.toHaveBeenCalled()
|
|
|
|
subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07')
|
|
const first = session.confirmShellForeground()
|
|
const second = session.confirmShellForeground()
|
|
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
|
|
resolveConfirmation?.(true)
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
|
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('reuses the parser confirmation for a concurrent runtime request', async () => {
|
|
let resolveConfirmation: ((confirmed: boolean) => void) | undefined
|
|
subprocess.confirmShellForeground.mockImplementation(
|
|
() => new Promise((resolve) => void (resolveConfirmation = resolve))
|
|
)
|
|
createSession()
|
|
|
|
subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker')
|
|
const runtimeConfirmation = session.confirmShellForeground()
|
|
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
|
|
resolveConfirmation?.(true)
|
|
|
|
await expect(runtimeConfirmation).resolves.toBe(true)
|
|
await vi.waitFor(() => expect(session.getSnapshot()?.terminalOwner).toBe('shell'))
|
|
})
|
|
|
|
it('forwards subprocess data to attached clients', () => {
|
|
createSession()
|
|
const received: string[] = []
|
|
session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
subprocess.simulateData('hello')
|
|
expect(received).toEqual(['hello'])
|
|
})
|
|
|
|
it('does not deliver data to detached clients', () => {
|
|
createSession()
|
|
const received: string[] = []
|
|
const token = session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
session.detachClient(token)
|
|
subprocess.simulateData('should not arrive')
|
|
expect(received).toEqual([])
|
|
})
|
|
|
|
it('supports multiple attached clients', () => {
|
|
createSession()
|
|
const received1: string[] = []
|
|
const received2: string[] = []
|
|
session.attachClient({ onData: (d) => received1.push(d), onExit: () => {} })
|
|
session.attachClient({ onData: (d) => received2.push(d), onExit: () => {} })
|
|
|
|
subprocess.simulateData('broadcast')
|
|
expect(received1).toEqual(['broadcast'])
|
|
expect(received2).toEqual(['broadcast'])
|
|
})
|
|
|
|
it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => {
|
|
createSession({
|
|
ownerBackend: 'windows-conpty',
|
|
startupIngress: {
|
|
colors: { foreground: '#2e3434', background: '#ffffff' },
|
|
deadlineMs: 5_000
|
|
}
|
|
})
|
|
const onData = vi.fn()
|
|
session.attachClient({ onData, onExit: () => {} })
|
|
const query = '\x1b]10;?\x07'
|
|
const echo = ']10;rgb:2e2e/3434/3434\\'
|
|
|
|
subprocess.simulateData(query)
|
|
subprocess.simulateData(echo)
|
|
subprocess.simulateData('prompt')
|
|
|
|
expect(subprocess.written).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\'])
|
|
expect(onData.mock.calls).toEqual([
|
|
['', query.length, true, query.length],
|
|
['', echo.length, true, query.length + echo.length],
|
|
['prompt']
|
|
])
|
|
expect(session.takePendingOutput(false)?.records).toEqual([
|
|
{ kind: 'output', data: 'prompt' }
|
|
])
|
|
expect(session.getSnapshot()).toMatchObject({
|
|
outputSequence: query.length + echo.length + 'prompt'.length
|
|
})
|
|
expect(session.getSnapshot()?.snapshotAnsi).toContain('prompt')
|
|
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
|
|
})
|
|
|
|
it('releases a held cooked-echo prefix before taking a snapshot', () => {
|
|
createSession({
|
|
ownerBackend: 'windows-conpty',
|
|
startupIngress: {
|
|
colors: { foreground: '#2e3434', background: '#ffffff' },
|
|
deadlineMs: 5_000
|
|
}
|
|
})
|
|
subprocess.simulateData('\x1b]10;?\x07')
|
|
subprocess.simulateData(']10;rgb:2e2e/')
|
|
|
|
const snapshot = session.getSnapshot()
|
|
|
|
expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/')
|
|
expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length)
|
|
})
|
|
|
|
it('contains legacy paired-runtime reply echoes and removes their downstream producer', async () => {
|
|
const query = '\x1b]10;?\x07'
|
|
const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\'
|
|
const projectedEcho = reply.replaceAll('\x1b', '^[')
|
|
createSession({ ownerBackend: 'posix-pty' })
|
|
session.closeStartupQueryAuthority()
|
|
const legacyReplyProducers: string[] = []
|
|
const legacyOnData = vi.fn((data: string) => {
|
|
if (data === query) {
|
|
legacyReplyProducers.push('remote-visible-renderer')
|
|
session.write(reply)
|
|
}
|
|
})
|
|
session.attachClient({ onData: legacyOnData, onExit: () => {} })
|
|
|
|
subprocess.simulateData(query)
|
|
|
|
expect(legacyReplyProducers).toEqual(['remote-visible-renderer'])
|
|
// Written in the calling turn — the echo is contained on the output side below,
|
|
// not by withholding the write.
|
|
expect(subprocess.written).toEqual([reply])
|
|
subprocess.simulateData(projectedEcho)
|
|
expect(legacyOnData.mock.calls).toEqual([
|
|
[query],
|
|
['', projectedEcho.length, true, query.length + projectedEcho.length]
|
|
])
|
|
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
|
|
session.dispose()
|
|
|
|
subprocess = createMockSubprocess()
|
|
createSession({ ownerBackend: 'windows-conpty' })
|
|
session.closeStartupQueryAuthority()
|
|
const fixedReplyProducers: string[] = []
|
|
const fixedOnData = vi.fn((data: string) => {
|
|
if (data === query) {
|
|
fixedReplyProducers.push('remote-visible-renderer')
|
|
session.write(reply)
|
|
}
|
|
})
|
|
session.attachClient({ onData: fixedOnData, onExit: () => {} })
|
|
|
|
subprocess.simulateData(query)
|
|
subprocess.simulateData('prompt')
|
|
|
|
expect(fixedReplyProducers).toEqual([])
|
|
expect(subprocess.written).toEqual([])
|
|
expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']])
|
|
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
|
|
})
|
|
})
|
|
|
|
describe('write', () => {
|
|
it('forwards writes to subprocess when running', () => {
|
|
createSession({ shellReadySupported: false })
|
|
session.write('ls\n')
|
|
expect(subprocess.written).toEqual(['ls\n'])
|
|
})
|
|
})
|
|
|
|
describe('emulator does not reply to terminal queries', () => {
|
|
// Why: daemon emulator parses in-process synchronously — before
|
|
// handleSubprocessData forwards bytes onward — so any auto-reply it
|
|
// emits races ahead of the live answerer and clobbers it with
|
|
// default-xterm values (no theme, stale cursor). Query authority is
|
|
// structural (terminal-query-authority.md): a delivered chunk is
|
|
// answered by the consuming view's xterm, a hidden-dropped chunk by
|
|
// MAIN's runtime model responder. The daemon emulator is neither — it
|
|
// stays write-only forever, and these pins are permanent.
|
|
it.each([
|
|
['OSC 10 foreground-color', '\x1b]10;?\x07'],
|
|
['OSC 11 background-color', '\x1b]11;?\x07'],
|
|
['OSC 12 cursor-color', '\x1b]12;?\x1b\\'],
|
|
['DA1 device-attributes', '\x1b[c'],
|
|
['DA2 secondary device-attributes', '\x1b[>c'],
|
|
['DSR terminal status', '\x1b[5n'],
|
|
['DSR cursor-position', '\x1b[6n'],
|
|
['DECRPM bracketed-paste mode', '\x1b[?2004$p']
|
|
])('does not reply to %s query', async (_label, query) => {
|
|
createSession({ shellReadySupported: false })
|
|
subprocess.simulateData(query)
|
|
// xterm.js fires terminal.write's completion callback via a microtask;
|
|
// two resolved-promise awaits flush any nested scheduling.
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(subprocess.written).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('shell readiness gating', () => {
|
|
// Why: the renderer's DA1 reply would be queued here, and a shell that withholds its
|
|
// first prompt until DA1 is answered never emits the marker that would release it.
|
|
it('answers DA1 once without forwarding it to a renderer', () => {
|
|
createSession({ shellReadySupported: true })
|
|
const onData = vi.fn((d: string) => d === '\x1b[0c' && session.write('\x1b[?1;2c'))
|
|
session.attachClient({ onData, onExit: () => {} })
|
|
|
|
subprocess.simulateData('\x1b[0c')
|
|
|
|
expect(onData).toHaveBeenCalledWith('', '\x1b[0c'.length, true, '\x1b[0c'.length)
|
|
expect(session.takePendingOutput(false)?.records).toEqual([])
|
|
expect(session.getSnapshot()?.outputSequence).toBe('\x1b[0c'.length)
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready\x07prompt')
|
|
vi.advanceTimersByTime(30)
|
|
expect(subprocess.written).toEqual(['\x1b[?1;2c'])
|
|
})
|
|
|
|
// Why: regression guard for "claude claude" double-echo. The marker fires
|
|
// from precmd before readline switches the PTY into raw mode; flushing
|
|
// then lets the kernel re-echo the command under the prompt. Detailed
|
|
// timing behavior is covered by post-ready-flush-gate.test.ts.
|
|
// Also checks writes that arrive during the gate window keep their order
|
|
// — the gate continues to queue even though shellState is already 'ready'.
|
|
it('defers flush past the shell-ready marker and preserves write order', () => {
|
|
createSession({ shellReadySupported: true })
|
|
expect(session.shellState).toBe('pending')
|
|
|
|
session.write('first\n')
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready\x07')
|
|
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
|
|
session.write('second\n')
|
|
expect(subprocess.written).toEqual([])
|
|
|
|
subprocess.simulateData('\r\nuser@host $ ')
|
|
vi.advanceTimersByTime(30)
|
|
expect(subprocess.written).toEqual(['first\n', 'second\n'])
|
|
})
|
|
|
|
it('contains live color replies without releasing queued startup input', async () => {
|
|
const reply = '\x1b[?997;1n'
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
|
session.write('codex\n')
|
|
|
|
session.write(reply)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(subprocess.written).toEqual([reply])
|
|
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
expect(subprocess.written).toEqual([reply, 'codex\n'])
|
|
})
|
|
|
|
it('uses the short settle path when marker and prompt bytes arrive together', () => {
|
|
createSession({ shellReadySupported: true })
|
|
session.write('codex\n')
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
|
|
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
|
|
vi.advanceTimersByTime(29)
|
|
expect(subprocess.written).toEqual([])
|
|
|
|
vi.advanceTimersByTime(1)
|
|
expect(subprocess.written).toEqual(['codex\n'])
|
|
})
|
|
|
|
it('does not treat bytes before the marker as post-marker prompt output', () => {
|
|
createSession({ shellReadySupported: true })
|
|
session.write('codex\n')
|
|
|
|
subprocess.simulateData('last login\r\n\x1b]777;orca-shell-ready\x07')
|
|
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
|
|
vi.advanceTimersByTime(30)
|
|
expect(subprocess.written).toEqual([])
|
|
|
|
subprocess.simulateData('\r\nuser@host $ ')
|
|
vi.advanceTimersByTime(30)
|
|
expect(subprocess.written).toEqual(['codex\n'])
|
|
})
|
|
|
|
it('strips shell-ready marker bytes before client and pending-output fan-out', () => {
|
|
createSession({ shellReadySupported: true })
|
|
const received: string[] = []
|
|
session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
subprocess.simulateData('hello \x1b]777;orca-shell-ready\x07% ')
|
|
|
|
expect(received).toEqual(['hello % '])
|
|
expect(session.takePendingOutput(false)?.records).toEqual([
|
|
{ kind: 'output', data: 'hello % ' }
|
|
])
|
|
expect(session.getSnapshot()?.snapshotAnsi).toContain('hello % ')
|
|
expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready')
|
|
})
|
|
|
|
it.each([
|
|
['after the ready marker', ['\x1b]777;orca-shell-ready\x07', '\x1b[?2004hfish> ']],
|
|
['after the ESC introducer', ['\x1b]777;orca-shell-ready\x07\x1b', '[?2004hfish> ']]
|
|
])('preserves Fish bracketed-paste output split %s', (_boundary, chunks) => {
|
|
createSession({ shellReadySupported: true })
|
|
const received: string[] = []
|
|
session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
for (const chunk of chunks) {
|
|
subprocess.simulateData(chunk)
|
|
}
|
|
|
|
const output = received.join('')
|
|
expect(output).toBe('\x1b[?2004hfish> ')
|
|
const rendered = new HeadlessEmulator({ cols: 80, rows: 24 })
|
|
expect(rendered.writeSync(output)).toBe(true)
|
|
expect(rendered.getVisibleLines().join('\n')).not.toContain('[?2004h')
|
|
rendered.dispose()
|
|
})
|
|
|
|
it('publishes an absolute output sequence with live snapshots', () => {
|
|
createSession()
|
|
subprocess.simulateData('first')
|
|
subprocess.simulateData('🟢second')
|
|
|
|
expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length)
|
|
expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length)
|
|
})
|
|
|
|
it('releases held marker-prefix bytes before flushing queued input on timeout', () => {
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
|
const received: string[] = []
|
|
session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready')
|
|
session.write('codex\n')
|
|
vi.advanceTimersByTime(100)
|
|
|
|
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
|
|
expect(received).toEqual(['\x1b]777;orca-shell-ready'])
|
|
expect(session.takePendingOutput(false)?.records).toEqual([
|
|
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
|
|
])
|
|
expect(subprocess.written).toEqual(['codex\n'])
|
|
})
|
|
|
|
it('releases held marker-prefix bytes when the subprocess exits before readiness', () => {
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
|
const received: string[] = []
|
|
session.attachClient({
|
|
onData: (data) => received.push(data),
|
|
onExit: () => {}
|
|
})
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready')
|
|
subprocess.simulateExit(0)
|
|
|
|
expect(received).toEqual(['\x1b]777;orca-shell-ready'])
|
|
expect(session.takePendingOutput(false)?.records).toEqual([
|
|
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
|
|
])
|
|
})
|
|
|
|
it('exposes drained output beside an includeSnapshot take without changing records', () => {
|
|
createSession()
|
|
subprocess.simulateData('kept-for-durable-history\r\n')
|
|
|
|
const taken = session.takePendingOutput(true)
|
|
|
|
expect(taken?.records).toEqual([])
|
|
expect(taken?.drainedRecords).toEqual([
|
|
{ kind: 'output', data: 'kept-for-durable-history\r\n' }
|
|
])
|
|
expect(taken?.snapshot).toBeTruthy()
|
|
})
|
|
|
|
it('keeps held marker-prefix bytes during live take-with-snapshot', () => {
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
|
session.write('codex\n')
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready')
|
|
const taken = session.takePendingOutput(true)
|
|
subprocess.simulateData('\x07\r\nuser@host $ ')
|
|
vi.advanceTimersByTime(30)
|
|
|
|
expect(taken?.records).toEqual([])
|
|
expect(taken?.drainedRecords).toEqual([])
|
|
expect(taken?.snapshot).toBeTruthy()
|
|
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
|
|
expect(subprocess.written).toEqual(['codex\n'])
|
|
})
|
|
|
|
it('releases held marker-prefix bytes before final take-with-snapshot', () => {
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready')
|
|
const taken = session.takePendingOutput(true, { teardownSnapshot: true })
|
|
|
|
expect(taken?.records).toEqual([{ kind: 'output', data: '\x1b]777;orca-shell-ready' }])
|
|
expect(taken?.snapshot).toBeTruthy()
|
|
})
|
|
|
|
it('cancels the post-ready flush gate when force-disposing the subprocess', async () => {
|
|
createSession({ shellReadySupported: true })
|
|
session.write('codex\n')
|
|
|
|
subprocess.simulateData('\x1b]777;orca-shell-ready\x07')
|
|
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
|
|
const dispose = session.forceKillAndDisposeSubprocess()
|
|
subprocess.simulateExit(137)
|
|
await dispose
|
|
vi.advanceTimersByTime(500)
|
|
|
|
expect(subprocess.written).toEqual([])
|
|
})
|
|
|
|
it('transitions to timed_out after 15 seconds', () => {
|
|
createSession({ shellReadySupported: true })
|
|
session.write('waiting input')
|
|
|
|
vi.advanceTimersByTime(15_000)
|
|
|
|
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
|
|
expect(subprocess.written).toEqual(['waiting input'])
|
|
})
|
|
|
|
// Why this matters: the detached daemon runs with stdio 'ignore', so a
|
|
// console.warn here reaches nobody. This path costs every startup command
|
|
// the full timeout, and diagnosing it from a silent log is what made the
|
|
// original report expensive -- so it has to reach the daemon's file log.
|
|
it('reports the timeout to the daemon log rather than the void', () => {
|
|
const events: { event: string; details: Record<string, unknown> }[] = []
|
|
createSession({
|
|
shellReadySupported: true,
|
|
reportReadinessEvent: (event, details) => events.push({ event, details })
|
|
})
|
|
|
|
vi.advanceTimersByTime(15_000)
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]?.event).toBe('shell-ready-timeout')
|
|
expect(events[0]?.details).toMatchObject({ sessionId: 'test-session', timeoutMs: 15_000 })
|
|
// Why a basename: the shell path can carry a home dir, and the basename is
|
|
// all a diagnosis needs.
|
|
expect(String(events[0]?.details.shell)).not.toContain('/')
|
|
})
|
|
|
|
// Why: the report runs before the transition that releases held PTY bytes and
|
|
// flushes queued stdin, and the ready timer is already cleared by then. A
|
|
// throwing sink must not leave the barrier stuck in `pending` forever.
|
|
it('still releases the barrier when the diagnostic sink throws', () => {
|
|
createSession({
|
|
shellReadySupported: true,
|
|
reportReadinessEvent: () => {
|
|
throw new Error('log sink unavailable')
|
|
}
|
|
})
|
|
session.write('waiting input')
|
|
|
|
vi.advanceTimersByTime(15_000)
|
|
|
|
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
|
|
expect(subprocess.written).toEqual(['waiting input'])
|
|
})
|
|
|
|
it('honors a shorter shell-ready timeout for Codex startup sessions', () => {
|
|
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 300 })
|
|
session.write('codex\n')
|
|
|
|
vi.advanceTimersByTime(299)
|
|
expect(subprocess.written).toEqual([])
|
|
|
|
vi.advanceTimersByTime(1)
|
|
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
|
|
expect(subprocess.written).toEqual(['codex\n'])
|
|
})
|
|
|
|
it('detects marker split across data chunks', () => {
|
|
createSession({ shellReadySupported: true })
|
|
|
|
subprocess.simulateData('\x1b]777;orca-sh')
|
|
expect(session.shellState).toBe('pending')
|
|
|
|
subprocess.simulateData('ell-ready\x07')
|
|
expect(session.shellState).toBe('ready')
|
|
})
|
|
})
|
|
|
|
describe('kill', () => {
|
|
it('kills the subprocess', () => {
|
|
createSession()
|
|
session.kill()
|
|
expect(subprocess.killed).toBe(true)
|
|
expect(session.isTerminating).toBe(true)
|
|
})
|
|
|
|
it('allows a graceful retry when the first kill is rejected', () => {
|
|
let attempts = 0
|
|
subprocess.kill = () => {
|
|
attempts++
|
|
if (attempts === 1) {
|
|
throw new Error('graceful kill rejected')
|
|
}
|
|
}
|
|
createSession()
|
|
|
|
expect(() => session.kill()).toThrow('graceful kill rejected')
|
|
expect(session.isTerminating).toBe(false)
|
|
expect(() => session.kill()).not.toThrow()
|
|
|
|
expect(attempts).toBe(2)
|
|
expect(session.isTerminating).toBe(true)
|
|
})
|
|
|
|
it('non-agent kill stays synchronous and never routes through the descendant sweep', () => {
|
|
createSession()
|
|
session.kill()
|
|
expect(subprocess.killed).toBe(true)
|
|
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('agent kill routes through the descendant sweep with the subprocess as root', () => {
|
|
createSession({ launchAgent: 'claude' })
|
|
session.kill()
|
|
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
|
|
subprocess.pid,
|
|
expect.any(Function),
|
|
expect.objectContaining({ ownsRoot: expect.any(Function) })
|
|
)
|
|
// The root kill is deferred to the sweep's snapshot-first sequencing.
|
|
expect(subprocess.killed).toBe(false)
|
|
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
|
|
killRoot()
|
|
expect(subprocess.killed).toBe(true)
|
|
})
|
|
|
|
it('agent kill hands the sweep the pty job, which outlives a reparented child', () => {
|
|
// A grandchild that detached leaves the shell's console and reparents, so
|
|
// the pid walk behind the sweep's fallback cannot see it. Only the job can.
|
|
createSession({ launchAgent: 'claude' })
|
|
session.kill()
|
|
const deps = killWithDescendantSweepMock.mock.calls[0][2] as {
|
|
terminateOwnedTree?: () => string
|
|
}
|
|
expect(deps.terminateOwnedTree?.()).toBe('terminated')
|
|
})
|
|
|
|
it('agent kill root callback is a no-op after the session already exited', () => {
|
|
createSession({ launchAgent: 'claude' })
|
|
session.kill()
|
|
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
|
|
subprocess.simulateExit(0)
|
|
killRoot()
|
|
expect(subprocess.killed).toBe(false)
|
|
})
|
|
|
|
it('notifies attached clients on exit after kill', async () => {
|
|
vi.useRealTimers()
|
|
createSession()
|
|
const exitCodes: number[] = []
|
|
session.attachClient({
|
|
onData: () => {},
|
|
onExit: (code) => exitCodes.push(code)
|
|
})
|
|
|
|
session.kill()
|
|
|
|
// Wait for the simulated async exit
|
|
await new Promise((r) => setTimeout(r, 20))
|
|
expect(exitCodes).toEqual([0])
|
|
})
|
|
|
|
it('force-kills after 5s but retains ownership until subprocess exit', () => {
|
|
createSession()
|
|
// Override kill to NOT trigger exit
|
|
subprocess.kill = () => {}
|
|
const forceKillSpy = vi.spyOn(subprocess, 'forceKill')
|
|
|
|
session.kill()
|
|
expect(session.state).not.toBe('exited')
|
|
|
|
vi.advanceTimersByTime(5_000)
|
|
expect(forceKillSpy).toHaveBeenCalled()
|
|
expect(session.state).toBe('running')
|
|
expect(session.isTerminating).toBe(true)
|
|
|
|
subprocess.simulateExit(137)
|
|
expect(session.state).toBe('exited')
|
|
})
|
|
|
|
it('retries a rejected destructive force kill while retaining ownership', async () => {
|
|
let forceKillAttempts = 0
|
|
subprocess.forceKill = () => {
|
|
forceKillAttempts++
|
|
if (forceKillAttempts === 1) {
|
|
throw new Error('force kill rejected')
|
|
}
|
|
}
|
|
createSession()
|
|
|
|
const shutdown = session.forceKillAndWaitForExit()
|
|
expect(forceKillAttempts).toBe(1)
|
|
await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS)
|
|
expect(forceKillAttempts).toBe(2)
|
|
subprocess.simulateExit(137)
|
|
await shutdown
|
|
|
|
expect(session.state).toBe('exited')
|
|
})
|
|
|
|
it('retries a rejected graceful-deadline force kill', async () => {
|
|
subprocess.kill = () => {}
|
|
let forceKillAttempts = 0
|
|
subprocess.forceKill = () => {
|
|
forceKillAttempts++
|
|
if (forceKillAttempts === 1) {
|
|
throw new Error('transient graceful fallback failure')
|
|
}
|
|
}
|
|
createSession()
|
|
|
|
session.kill()
|
|
await vi.advanceTimersByTimeAsync(5_000)
|
|
expect(forceKillAttempts).toBe(1)
|
|
expect(session.isTerminating).toBe(true)
|
|
|
|
await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS)
|
|
expect(forceKillAttempts).toBe(2)
|
|
subprocess.simulateExit(137)
|
|
expect(session.state).toBe('exited')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('keeps late data and the real exit code until physical exit', () => {
|
|
createSession()
|
|
subprocess.kill = () => {}
|
|
const onData = vi.fn()
|
|
const onExit = vi.fn()
|
|
session.attachClient({ onData, onExit })
|
|
|
|
session.kill()
|
|
vi.advanceTimersByTime(5_000)
|
|
|
|
subprocess.simulateData('late output')
|
|
subprocess.simulateExit(23)
|
|
|
|
expect(onData).toHaveBeenCalledWith('late output')
|
|
expect(onExit).toHaveBeenCalledTimes(1)
|
|
expect(onExit).toHaveBeenCalledWith(23, session.incarnationId, {
|
|
kind: 'exited',
|
|
exitCode: 23
|
|
})
|
|
expect(session.exitCode).toBe(23)
|
|
})
|
|
})
|
|
|
|
describe('signal', () => {
|
|
it('forwards signal to subprocess without entering terminating state', () => {
|
|
createSession()
|
|
session.signal('SIGINT')
|
|
expect(subprocess.signals).toEqual(['SIGINT'])
|
|
expect(session.isTerminating).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('snapshot', () => {
|
|
it('parses live OSC-7 output in the session WSL distro', () => {
|
|
createSession({ wslDistro: 'Ubuntu' })
|
|
|
|
subprocess.simulateData('\x1b]7;file://DESKTOP-ORCA/home/jin/repo\x07')
|
|
|
|
expect(session.getCwd()).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')
|
|
})
|
|
it('returns a terminal snapshot', async () => {
|
|
createSession()
|
|
subprocess.simulateData('$ hello\r\n')
|
|
// Give emulator time to process
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
const snapshot = session.getSnapshot()
|
|
expect(snapshot).toBeDefined()
|
|
expect(snapshot!.cols).toBe(80)
|
|
expect(snapshot!.rows).toBe(24)
|
|
})
|
|
|
|
it('returns null after session is disposed', () => {
|
|
createSession()
|
|
session.dispose()
|
|
expect(session.getSnapshot()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('resize', () => {
|
|
it('resizes the emulator and subprocess', () => {
|
|
createSession()
|
|
const resizeSpy = vi.spyOn(subprocess, 'resize')
|
|
session.resize(120, 40)
|
|
expect(resizeSpy).toHaveBeenCalledWith(120, 40)
|
|
})
|
|
|
|
it('same-dim resize passes through without tricks', () => {
|
|
createSession({ cols: 80, rows: 24 })
|
|
const resizeSpy = vi.spyOn(subprocess, 'resize')
|
|
session.resize(80, 24)
|
|
expect(resizeSpy).toHaveBeenCalledTimes(1)
|
|
expect(resizeSpy).toHaveBeenCalledWith(80, 24)
|
|
})
|
|
})
|
|
|
|
describe('detach token guard', () => {
|
|
it('ignores stale detach with wrong token', () => {
|
|
createSession()
|
|
const received: string[] = []
|
|
const token1 = session.attachClient({
|
|
onData: (d) => received.push(d),
|
|
onExit: () => {}
|
|
})
|
|
|
|
// Attach a second client (same conceptual slot but new token)
|
|
session.attachClient({
|
|
onData: (d) => received.push(d),
|
|
onExit: () => {}
|
|
})
|
|
|
|
// Try detaching with the old token — should only remove token1's client
|
|
session.detachClient(token1)
|
|
received.length = 0
|
|
|
|
subprocess.simulateData('after detach')
|
|
// token2's client should still receive data
|
|
expect(received).toEqual(['after detach'])
|
|
})
|
|
})
|
|
|
|
describe('dispose', () => {
|
|
it('cleans up without throwing', () => {
|
|
createSession()
|
|
expect(() => session.dispose()).not.toThrow()
|
|
})
|
|
|
|
it('marks session as exited', () => {
|
|
createSession()
|
|
session.dispose()
|
|
expect(session.state).toBe('exited')
|
|
})
|
|
})
|
|
})
|