fix: restore hidden remote terminal output snapshots (#4759)

This commit is contained in:
Neil 2026-06-06 23:37:05 -07:00 committed by GitHub
parent ca5ccdd126
commit 9db0985c7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1522 additions and 84 deletions

View File

@ -4920,7 +4920,8 @@ describe('registerPtyHandlers', () => {
data: 'snapshot\r\n',
cols: 120,
rows: 40,
seq: 42
seq: 42,
source: 'headless'
})
}
handlers.clear()
@ -4934,7 +4935,13 @@ describe('registerPtyHandlers', () => {
expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', {
scrollbackRows: 50_000
})
expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, seq: 42 })
expect(result).toEqual({
data: 'snapshot\r\n',
cols: 120,
rows: 40,
seq: 42,
source: 'headless'
})
})
})
})

View File

@ -1715,7 +1715,13 @@ export function registerPtyHandlers(
async (
_event,
args: { id?: unknown; opts?: { scrollbackRows?: unknown } }
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => {
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
return null
}

View File

@ -145,6 +145,7 @@ describe('mobile subscribe integration', () => {
runtime.onPtyData('pty-1', first, Date.now())
const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
expect(initialSnapshot?.seq).toBe(first.length)
expect(initialSnapshot?.source).toBe('headless')
type HeadlessStateForTest = {
emulator: { write: (data: string) => Promise<void> | void }
@ -179,12 +180,14 @@ describe('mobile subscribe integration', () => {
const snapshot = await racedSnapshot
expect(snapshot?.seq).toBe(first.length + second.length)
expect(snapshot?.source).toBe('headless')
expect(runtime.getPtyOutputSequence('pty-1')).toBe(
first.length + second.length + third.length
)
const finalSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
expect(finalSnapshot?.seq).toBe(first.length + second.length + third.length)
expect(finalSnapshot?.source).toBe('headless')
} finally {
headless!.emulator.write = originalWrite
secondWriteGate.release?.()
@ -222,7 +225,8 @@ describe('mobile subscribe integration', () => {
data: '',
cols: 90,
rows: 30,
seq: 17
seq: 17,
source: 'headless'
})
await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull()
})

View File

@ -1289,7 +1289,10 @@ export class OrcaRuntimeService {
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
// These listeners fire on every onPtyData call, enabling real-time streaming
// without polling. Keyed by ptyId for O(1) lookup per data event.
private dataListeners = new Map<string, Set<(data: string) => void>>()
private dataListeners = new Map<
string,
Set<(data: string, meta?: { seq?: number; rawLength?: number }) => void>
>()
// Why: startup draft paste can subscribe after the agent already emitted its
// ready marker. Keep a bounded raw buffer so fast startup output is replayed.
private recentPtyOutputById = new Map<string, string>()
@ -3106,8 +3109,9 @@ export class OrcaRuntimeService {
const listeners = this.dataListeners.get(ptyId)
if (listeners) {
const meta = { seq: outputSequence, rawLength: data.length }
for (const listener of listeners) {
listener(data)
listener(data, meta)
}
}
return outputSequence
@ -3117,7 +3121,10 @@ export class OrcaRuntimeService {
return this.ptyOutputSequenceById.get(ptyId) ?? 0
}
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
subscribeToTerminalData(
ptyId: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
): () => void {
let listeners = this.dataListeners.get(ptyId)
if (!listeners) {
listeners = new Set()
@ -3183,14 +3190,26 @@ export class OrcaRuntimeService {
serializeTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
serializeMainTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> {
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
}
@ -3385,7 +3404,13 @@ export class OrcaRuntimeService {
private async serializeTerminalBufferFromAvailableState(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
return headlessSnapshot
@ -3412,15 +3437,21 @@ export class OrcaRuntimeService {
// below can still preserve colored terminal state.
}
if (rendererSnapshot && rendererSnapshot.data.length > 0) {
return rendererSnapshot
return { ...rendererSnapshot, source: 'renderer' }
}
return rendererSnapshot
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
}
private async serializeHeadlessTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number; includeEmpty?: boolean } = {}
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless'
} | null> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return null
@ -3438,7 +3469,13 @@ export class OrcaRuntimeService {
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
return data.length > 0 || opts.includeEmpty === true
? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence }
? {
data,
cols: snapshot.cols,
rows: snapshot.rows,
seq: state.outputSequence,
source: 'headless'
}
: null
}

View File

@ -22,6 +22,7 @@ import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-educatio
// WebView's xterm has a 5000-row buffer so this fits comfortably.
const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000
const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
const TERMINAL_OUTPUT_FLUSH_MS = 5
const TERMINAL_OUTPUT_BATCH_MAX_CHARS = 64 * 1024
@ -33,17 +34,21 @@ type SnapshotFrameOptions = {
cols: number
rows: number
data: string
requestId?: number
displayMode?: string
reason?: string
seq?: number
truncated?: boolean
truncatedByByteBudget?: boolean
source?: 'headless' | 'renderer'
}
type SerializedSnapshot = {
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
scrollbackRows: number
truncatedByByteBudget: boolean
} | null
@ -60,8 +65,9 @@ type TerminalMultiplexStream = {
client: TerminalViewportClient | undefined
isMobile: boolean
buffering: boolean
pendingOutput: string[]
pendingOutput: TerminalOutputChunk[]
pendingOutputChars: number
pendingOutputOverflowed: boolean
outputBatcher: ReturnType<typeof createTerminalOutputBatcher>
unsubscribeData: () => void
unsubscribeResize: () => void
@ -70,13 +76,21 @@ type TerminalMultiplexStream = {
unregisterBinaryHandler: () => void
}
function createTerminalOutputBatcher(onFlush: (data: string) => void): {
push: (data: string) => void
type TerminalOutputChunk = {
data: string
meta?: { seq?: number; rawLength?: number }
}
function createTerminalOutputBatcher(
onFlush: (data: string, meta?: { seq?: number; rawLength?: number }) => void
): {
push: (data: string, meta?: { seq?: number; rawLength?: number }) => void
flush: () => void
dispose: () => void
} {
let chunks: string[] = []
let chars = 0
let lastSeq: number | undefined
let timer: ReturnType<typeof setTimeout> | null = null
const clearTimer = (): void => {
@ -93,18 +107,23 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
return
}
const data = chunks.length === 1 ? chunks[0]! : chunks.join('')
const meta = typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : undefined
chunks = []
chars = 0
onFlush(data)
lastSeq = undefined
onFlush(data, meta)
}
return {
push(data: string): void {
push(data: string, meta?: { seq?: number; rawLength?: number }): void {
if (!data) {
return
}
chunks.push(data)
chars += data.length
if (typeof meta?.seq === 'number') {
lastSeq = meta.seq
}
if (chars >= TERMINAL_OUTPUT_BATCH_MAX_CHARS) {
flush()
return
@ -157,28 +176,35 @@ function resolveMobileFloorClientId(
return null
}
function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void {
stream.pendingOutput.push(data)
function appendPendingMultiplexOutput(
stream: TerminalMultiplexStream,
data: string,
meta?: { seq?: number; rawLength?: number }
): void {
stream.pendingOutput.push({ data, meta })
stream.pendingOutputChars += data.length
stream.pendingOutputChars = trimPendingOutputToBudget(
stream.pendingOutput,
stream.pendingOutputChars
)
const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputChars)
stream.pendingOutputChars = trimmed.chars
stream.pendingOutputOverflowed ||= trimmed.overflowed
}
function trimPendingOutputToBudget(pendingOutput: string[], pendingOutputChars: number): number {
function trimPendingOutputToBudget(
pendingOutput: (string | TerminalOutputChunk)[],
pendingOutputChars: number
): { chars: number; overflowed: boolean } {
let omittedChunkCount = 0
while (
pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS &&
omittedChunkCount < pendingOutput.length
) {
pendingOutputChars -= pendingOutput[omittedChunkCount].length
const chunk = pendingOutput[omittedChunkCount]
pendingOutputChars -= typeof chunk === 'string' ? chunk.length : chunk.data.length
omittedChunkCount += 1
}
if (omittedChunkCount > 0) {
pendingOutput.splice(0, omittedChunkCount)
}
return pendingOutputChars
return { chars: pendingOutputChars, overflowed: omittedChunkCount > 0 }
}
function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean {
@ -187,6 +213,43 @@ function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: b
return read.truncated || read.limited === true
}
function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined
}
return Math.max(0, Math.min(50_000, Math.floor(value)))
}
function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] {
const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0]
.filter((rows): rows is number => typeof rows === 'number')
.map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows))))
return [...new Set(candidates)]
}
async function serializeBudgetedRequestedSnapshot(
runtime: OrcaRuntimeService,
ptyId: string,
scrollbackRows: number | undefined
): Promise<SerializedSnapshot> {
const requestedRows = scrollbackRows ?? 0
for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) {
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows })
if (!serialized) {
return null
}
const bytes = new TextEncoder().encode(serialized.data).byteLength
if (bytes <= REQUESTED_SNAPSHOT_BYTE_BUDGET || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget: rows < requestedRows || bytes > REQUESTED_SNAPSHOT_BYTE_BUDGET
}
}
}
return null
}
function sendSnapshotFrames(
sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array<ArrayBufferLike>) => void,
options: SnapshotFrameOptions
@ -197,9 +260,11 @@ function sendSnapshotFrames(
kind: options.kind,
cols: options.cols,
rows: options.rows,
requestId: options.requestId,
displayMode: options.displayMode,
reason: options.reason,
seq: options.seq,
source: options.source,
truncated: options.truncated === true,
truncatedByByteBudget: options.truncatedByByteBudget === true
})
@ -404,6 +469,11 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
viewport: TerminalViewport.optional()
})
const TerminalMultiplexSnapshotRequestFrame = z.object({
requestId: z.number().int().positive().optional(),
scrollbackRows: z.number().finite().optional()
})
const TerminalSetDisplayMode = TerminalHandle.extend({
// Why: 'phone' was previously a "stay at phone dims after unsubscribe"
// mode that the toggle UI never produced and nothing in product
@ -732,12 +802,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const sendFrame = (
streamId: number,
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
seq?: number
): void => {
if (closed) {
return
}
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
sendBinary(
encodeTerminalStreamFrame({
opcode,
streamId,
seq: typeof seq === 'number' ? seq : cursor++,
payload
})
)
}
const sendStreamError = (streamId: number, message: string): void => {
sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message))
@ -817,6 +895,99 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
{ cols: viewport.cols, rows: viewport.rows },
stream.isMobile ? 'mobile' : 'desktop'
).catch(() => {})
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) {
const payload = TerminalMultiplexSnapshotRequestFrame.safeParse(
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
)
void sendRequestedSnapshot(stream, payload.success ? payload.data : {})
}
}
const sendRequestedSnapshot = async (
stream: TerminalMultiplexStream,
request: z.infer<typeof TerminalMultiplexSnapshotRequestFrame>
): Promise<void> => {
if (closed || streams.get(stream.streamId) !== stream) {
return
}
stream.outputBatcher.flush()
stream.pendingOutputOverflowed = false
stream.buffering = true
const requestId = request.requestId
try {
const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows)
let serialized = await serializeBudgetedRequestedSnapshot(
runtime,
stream.ptyId,
scrollbackRows
)
if (closed || streams.get(stream.streamId) !== stream) {
return
}
let size = runtime.getTerminalSize(stream.ptyId)
let displayMode = runtime.getMobileDisplayMode(stream.ptyId)
if (stream.pendingOutputOverflowed) {
// Why: the overflowed tail is newer than the first snapshot. Retry
// so hidden restore receives a current terminal image instead of null.
stream.pendingOutput.splice(0)
stream.pendingOutputChars = 0
stream.pendingOutputOverflowed = false
serialized = await serializeBudgetedRequestedSnapshot(
runtime,
stream.ptyId,
scrollbackRows
)
if (closed || streams.get(stream.streamId) !== stream) {
return
}
size = runtime.getTerminalSize(stream.ptyId)
displayMode = runtime.getMobileDisplayMode(stream.ptyId)
if (stream.pendingOutputOverflowed) {
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
kind: 'scrollback',
cols: size?.cols ?? 80,
rows: size?.rows ?? 24,
requestId,
displayMode,
truncated: true,
truncatedByByteBudget: false,
data: ''
})
return
}
}
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
requestId,
displayMode,
seq: serialized?.seq,
source: serialized?.source,
truncated: false,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
})
} catch (error) {
sendStreamError(
stream.streamId,
error instanceof Error ? error.message : 'Remote terminal snapshot failed.'
)
} finally {
if (streams.get(stream.streamId) === stream) {
const shouldFlushPendingOutput = !stream.pendingOutputOverflowed
stream.buffering = false
const pendingOutput = stream.pendingOutput.splice(0)
if (shouldFlushPendingOutput) {
for (const chunk of pendingOutput) {
stream.outputBatcher.push(chunk.data, chunk.meta)
}
}
stream.pendingOutputChars = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
}
}
}
const handleSubscribeFrame = async (payload: Uint8Array<ArrayBufferLike>): Promise<void> => {
@ -857,8 +1028,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
buffering: true,
pendingOutput: [],
pendingOutputChars: 0,
outputBatcher: createTerminalOutputBatcher((data) => {
sendFrame(request.streamId, TerminalStreamOpcode.Output, encodeTerminalStreamText(data))
pendingOutputOverflowed: false,
outputBatcher: createTerminalOutputBatcher((data, meta) => {
sendFrame(
request.streamId,
TerminalStreamOpcode.Output,
encodeTerminalStreamText(data),
meta?.seq
)
}),
unsubscribeData: () => {},
unsubscribeResize: () => {},
@ -872,15 +1049,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
)
try {
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed || streams.get(request.streamId) !== stream) {
return
}
if (stream.buffering) {
appendPendingMultiplexOutput(stream, data)
appendPendingMultiplexOutput(stream, data, meta)
return
}
stream.outputBatcher.push(data)
stream.outputBatcher.push(data, meta)
})
if (isMobile && request.client?.id) {
@ -924,7 +1101,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const seq = runtime.getLayout(ptyId)?.seq
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotSeq = serialized?.seq ?? layoutSeq
if (!isMobile) {
const fitOverride = runtime.getTerminalFitOverride(ptyId)
emit({
@ -947,7 +1125,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq,
seq: layoutSeq,
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)
})
sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), {
@ -955,16 +1133,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq,
seq: snapshotSeq,
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
})
stream.buffering = false
for (const data of stream.pendingOutput.splice(0)) {
stream.outputBatcher.push(data)
for (const chunk of stream.pendingOutput.splice(0)) {
stream.outputBatcher.push(chunk.data, chunk.meta)
}
stream.pendingOutputChars = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
@ -1000,10 +1180,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
}
const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => {
if (frame.opcode !== TerminalStreamOpcode.Subscribe) {
return
if (frame.opcode === TerminalStreamOpcode.Subscribe) {
void handleSubscribeFrame(frame.payload)
}
void handleSubscribeFrame(frame.payload)
})
runtime.registerSubscriptionCleanup(
@ -1238,7 +1417,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (buffering) {
pendingOutput.push(data)
pendingOutputChars += data.length
pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars)
pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars).chars
return
}
outputBatcher?.push(data)

View File

@ -185,6 +185,46 @@ describe('terminal multiplex RPC', () => {
rows: 40
})
const frameCountBeforeSnapshotRequest = binaryFrames.length
handlers.get(5)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotRequest,
streamId: 5,
seq: 4,
payload: encodeTerminalStreamJson({ requestId: 7, scrollbackRows: 5000 })
})
)!
)
await vi.waitFor(() =>
expect(
binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
).toBe(true)
)
const requestedSnapshotFrames = binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
const requestedSnapshotStart = requestedSnapshotFrames.find(
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
)
expect(
requestedSnapshotStart && decodeTerminalStreamJson(requestedSnapshotStart.payload)
).toMatchObject({
requestId: 7
})
expect(runtime.serializeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', {
scrollbackRows: 5000
})
expect(
requestedSnapshotFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('snapshot')
runtime.cleanupSubscription('terminal-multiplex:conn-1')
await dispatchPromise
} finally {
@ -288,6 +328,115 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('falls back to smaller requested snapshots when serialized data exceeds the send budget', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 })
.mockResolvedValueOnce({ data: 'x'.repeat(2 * 1024 * 1024 + 1), cols: 120, rows: 40 })
.mockResolvedValueOnce({ data: 'budgeted snapshot', cols: 120, rows: 40 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-budgeted-request',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 14,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
viewport: { cols: 120, rows: 40 }
})
})
)!
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
const frameCountBeforeSnapshotRequest = binaryFrames.length
handlers.get(14)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotRequest,
streamId: 14,
seq: 2,
payload: encodeTerminalStreamJson({ requestId: 55, scrollbackRows: 5000 })
})
)!
)
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3))
const requestedFrames = binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
const requestedStart = requestedFrames.find(
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
)
expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({
requestId: 55,
truncatedByByteBudget: true
})
expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(2, 'pty-1', {
scrollbackRows: 5000
})
expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(3, 'pty-1', {
scrollbackRows: 1000
})
expect(
requestedFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('budgeted snapshot')
cleanups.get('terminal-multiplex:conn-budgeted-request')?.()
await dispatchPromise
})
it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => {
const messages: string[] = []
const handlers = new Map<
@ -732,4 +881,155 @@ describe('terminal multiplex RPC', () => {
vi.useRealTimers()
}
})
it('retries requested snapshots after live output overflows during serialization', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const dataListenerRef: { current?: (data: string) => void } = {}
let resolveRequestedSnapshot: (value: {
data: string
cols: number
rows: number
}) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 })
.mockImplementationOnce(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
resolveRequestedSnapshot = resolve
})
)
.mockResolvedValueOnce({ data: 'retry snapshot', cols: 120, rows: 40 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-request-overflow',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 12,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
viewport: { cols: 120, rows: 40 }
})
})
)!
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
const frameCountBeforeSnapshotRequest = binaryFrames.length
handlers.get(12)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotRequest,
streamId: 12,
seq: 2,
payload: encodeTerminalStreamJson({ requestId: 44, scrollbackRows: 5000 })
})
)!
)
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
for (let index = 0; index < 400; index += 1) {
dataListenerRef.current?.(String(index).padStart(3, '0') + 'x'.repeat(1021))
}
resolveRequestedSnapshot({ data: 'requested', cols: 120, rows: 40 })
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3))
await vi.waitFor(() =>
expect(
binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
).toBe(true)
)
const requestedFrames = binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
const requestedStart = requestedFrames.find(
(frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart
)
expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({
requestId: 44,
truncated: false
})
expect(
requestedFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('retry snapshot')
expect(
requestedFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('')
dataListenerRef.current?.('live-after-overflow')
await vi.runOnlyPendingTimersAsync()
const outputAfterOverflow = binaryFrames
.slice(frameCountBeforeSnapshotRequest)
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(outputAfterOverflow).toBe('live-after-overflow')
cleanups.get('terminal-multiplex:conn-request-overflow')?.()
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
})

View File

@ -908,7 +908,13 @@ export type PreloadApi = {
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
) => Promise<{ data: string; cols: number; rows: number; seq?: number } | null>
) => Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null>
onData: (
callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void
) => () => void

View File

@ -693,8 +693,13 @@ const api = {
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> =>
ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
/** Check if a PTY's shell has child processes (e.g. a running command).
* Returns false for an idle shell prompt. */

View File

@ -104,6 +104,7 @@ type MockTransport = {
sendInputAccepted?: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
serializeBuffer?: ReturnType<typeof vi.fn>
}
const scheduleRuntimeGraphSync = vi.fn()
@ -221,7 +222,8 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
}),
sendInput: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId)
getPtyId: vi.fn(() => ptyId),
serializeBuffer: undefined
} as MockTransport
const sendInput = transport.sendInput as unknown as (data: string) => boolean
transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data))
@ -3169,6 +3171,110 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('restores skipped hidden remote runtime output from the transport snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('remote:env-1@@terminal-1')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.serializeBuffer = vi.fn().mockResolvedValue({
data: 'remote snapshot\r\n',
cols: 120,
rows: 40,
seq: 40,
source: 'headless'
})
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'remote:env-1@@terminal-1'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({ isVisibleRef: { current: false } })
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
const hidden = 'hidden remote output\r\n'
const live = 'visible remote output\r\n'
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function))
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(live, {
seq: 40 + live.length,
rawLength: live.length
})
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).not.toHaveBeenCalled()
expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 })
expect(pane.terminal.write).toHaveBeenCalledWith('remote snapshot\r\n', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function))
disposable.dispose()
})
it('retries hidden remote runtime restore after a null transport snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('remote:env-1@@terminal-1')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.serializeBuffer = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
data: 'remote recovered snapshot\r\n',
cols: 120,
rows: 40,
seq: 80,
source: 'headless'
})
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'remote:env-1@@terminal-1'
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({ isVisibleRef: { current: false } })
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
const hidden = 'hidden remote output\r\n'
const firstLive = 'first visible output\r\n'
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(firstLive, {
seq: hidden.length + firstLive.length,
rawLength: firstLive.length
})
await flushAsyncTicks(20)
expect(transport.serializeBuffer).toHaveBeenCalledTimes(1)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('Orca skipped hidden terminal output'),
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'remote recovered snapshot\r\n',
expect.any(Function)
)
await new Promise((resolve) => setTimeout(resolve, 80))
await flushAsyncTicks(20)
expect(transport.serializeBuffer).toHaveBeenCalledTimes(2)
expect(pane.terminal.write).toHaveBeenCalledWith(
'remote recovered snapshot\r\n',
expect.any(Function)
)
disposable.dispose()
})
it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')

View File

@ -9,7 +9,7 @@ import {
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import type { PtyConnectResult } from './pty-transport'
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
@ -88,6 +88,8 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000
const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
@ -108,15 +110,29 @@ const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
type E2eTerminalPtyDataInjectionApi = {
inject: (paneKey: string, data: string) => boolean
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
keys: () => string[]
}
type E2eTerminalPtyDataInjectionWindow = Window & {
__terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi
__terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi
}
const e2eTerminalPtyDataInjectors = new Map<string, (data: string) => void>()
const e2eTerminalPtyDataInjectors = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
type E2eTerminalHiddenSnapshotOverrideApi = {
setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void
resolve: (ptyId: string) => void
clear: (ptyId: string) => void
}
type E2eTerminalHiddenSnapshotOverride = {
promise: Promise<PtyBufferSnapshot | null>
resolve: () => void
}
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
type E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: number
@ -181,21 +197,39 @@ function exposeE2eTerminalPtyDataInjection(): void {
// e2e-only seam lets tests replay the renderer-side data callback exactly.
const target = window as E2eTerminalPtyDataInjectionWindow
target.__terminalPtyDataInjection ??= {
inject: (paneKey, data) => {
inject: (paneKey, data, meta) => {
const inject = e2eTerminalPtyDataInjectors.get(paneKey)
if (!inject) {
return false
}
inject(data)
inject(data, meta)
return true
},
keys: () => [...e2eTerminalPtyDataInjectors.keys()]
}
target.__terminalHiddenSnapshotOverride ??= {
setPending: (ptyId, snapshot) => {
let resolve = (): void => {}
const wait = new Promise<void>((nextResolve) => {
resolve = nextResolve
})
e2eTerminalHiddenSnapshotOverrides.set(ptyId, {
promise: wait.then(() => snapshot),
resolve
})
},
resolve: (ptyId) => {
e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve()
},
clear: (ptyId) => {
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
}
}
}
function registerE2eTerminalPtyDataInjection(
paneKey: string,
inject: (data: string) => void
inject: (data: string, meta?: PtyDataMeta) => void
): () => void {
if (!e2eConfig.exposeStore) {
return () => {}
@ -209,6 +243,23 @@ function registerE2eTerminalPtyDataInjection(
}
}
function readE2eHiddenSnapshotOverride(ptyId: string): Promise<PtyBufferSnapshot | null> | null {
if (!e2eConfig.exposeStore) {
return null
}
const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId)
if (!override) {
return null
}
// Why: visual E2E needs to hold a hidden restore snapshot in flight so a
// newer live TUI frame can race it deterministically.
return override.promise.finally(() => {
if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) {
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
}
})
}
function firstStartupCommandToken(command: string): string {
const trimmed = command.trim()
const quote = trimmed[0]
@ -546,6 +597,7 @@ export function connectPanePty(
let connectFrame: number | null = null
let unregisterBacklogRecovery: (() => void) | null = null
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
let unregisterE2ePtyDataInjection = (): void => {}
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
@ -1791,6 +1843,9 @@ export function connectPanePty(
let hiddenOutputRestorePendingChars = 0
let hiddenOutputRestorePendingOverflow = false
let hiddenOutputRestoreFreshSnapshotNeeded = false
let hiddenOutputRestoreRetryDeferred = false
let hiddenOutputRestoreDeferredRetryTimer: ReturnType<typeof setTimeout> | null = null
let hiddenOutputRestoreDeferredRetryAttempts = 0
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
// can reuse the pane object for a different session before visibility.
let hiddenOutputRestorePtyId: string | null = null
@ -1806,6 +1861,33 @@ export function connectPanePty(
return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
}
function canUseHiddenOutputSnapshot(ptyId: string | null): ptyId is string {
if (!ptyId) {
return false
}
if (canUseMainBufferSnapshot(ptyId)) {
return true
}
return transport.getPtyId() === ptyId && typeof transport.serializeBuffer === 'function'
}
async function serializeHiddenOutputSnapshot(
ptyId: string,
opts: { scrollbackRows?: number }
): Promise<PtyBufferSnapshot | null> {
const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId)
if (e2eSnapshot) {
return e2eSnapshot
}
if (canUseMainBufferSnapshot(ptyId)) {
return window.api.pty.getMainBufferSnapshot(ptyId, opts)
}
if (transport.getPtyId() !== ptyId || typeof transport.serializeBuffer !== 'function') {
return null
}
return transport.serializeBuffer(opts)
}
function isHiddenStartupRendererQueryWindowActive(): boolean {
return (
paneStartup !== null &&
@ -1936,7 +2018,7 @@ export function connectPanePty(
}
const parseHiddenStartupOutput =
!foreground &&
canUseMainBufferSnapshot(transport.getPtyId()) &&
canUseHiddenOutputSnapshot(transport.getPtyId()) &&
isHiddenStartupRendererQueryWindowActive()
const synchronizedOutputStarted =
shouldProtectNativeWindowsSynchronizedOutput &&
@ -1992,7 +2074,7 @@ export function connectPanePty(
function markHiddenOutputRestoreNeeded(): void {
const ptyId = transport.getPtyId()
if (!canUseMainBufferSnapshot(ptyId)) {
if (!canUseHiddenOutputSnapshot(ptyId)) {
return
}
if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) {
@ -2009,7 +2091,7 @@ export function connectPanePty(
return (
!foreground &&
!deps.isVisibleRef.current &&
canUseMainBufferSnapshot(transport.getPtyId()) &&
canUseHiddenOutputSnapshot(transport.getPtyId()) &&
!isHiddenStartupRendererQueryWindowActive()
)
}
@ -2028,7 +2110,7 @@ export function connectPanePty(
return
}
const ptyId = transport.getPtyId()
if (!canUseMainBufferSnapshot(ptyId)) {
if (!canUseHiddenOutputSnapshot(ptyId)) {
return
}
if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) {
@ -2115,6 +2197,44 @@ export function connectPanePty(
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
hiddenOutputRestoreFreshSnapshotNeeded = false
hiddenOutputRestoreRetryDeferred = false
clearHiddenOutputRestoreDeferredRetryTimer()
hiddenOutputRestoreDeferredRetryAttempts = 0
}
function clearHiddenOutputRestoreDeferredRetryTimer(): void {
if (hiddenOutputRestoreDeferredRetryTimer === null) {
return
}
clearTimeout(hiddenOutputRestoreDeferredRetryTimer)
hiddenOutputRestoreDeferredRetryTimer = null
}
cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer
function scheduleHiddenOutputRestoreDeferredRetry(): void {
if (
disposed ||
hiddenOutputRestoreDeferredRetryTimer !== null ||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
) {
return
}
if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) {
clearHiddenOutputRestoreState()
writeRestoreUnavailableWarning()
return
}
hiddenOutputRestoreDeferredRetryAttempts += 1
// Why: null requested snapshots usually mean remote output was still
// mutating. Retry after one quiet tick instead of spinning synchronously.
hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => {
hiddenOutputRestoreDeferredRetryTimer = null
if (disposed || !hiddenOutputRestoreNeeded) {
return
}
hiddenOutputRestoreRetryDeferred = false
requestHiddenOutputRestoreIfNeeded()
}, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS)
}
function clearHiddenOutputRestoreState(): void {
@ -2214,13 +2334,15 @@ export function connectPanePty(
if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) {
return false
}
if (!canUseMainBufferSnapshot(ptyId)) {
if (!canUseHiddenOutputSnapshot(ptyId)) {
return false
}
hiddenOutputRestorePtyId = ptyId
if (hiddenOutputRestoreInFlight) {
return true
}
clearHiddenOutputRestoreDeferredRetryTimer()
hiddenOutputRestoreRetryDeferred = false
hiddenOutputRestoreInFlight = (async () => {
while (!disposed) {
@ -2229,7 +2351,7 @@ export function connectPanePty(
clearHiddenOutputRestoreState()
return
}
if (!canUseMainBufferSnapshot(currentPtyId)) {
if (!canUseHiddenOutputSnapshot(currentPtyId)) {
if (hiddenOutputRestorePtyId === currentPtyId) {
clearHiddenOutputRestoreState()
}
@ -2244,9 +2366,9 @@ export function connectPanePty(
}
const restoreGeneration = hiddenOutputRestoreGeneration
hiddenOutputRestoreNeeded = false
let snapshot: { data: string; cols: number; rows: number; seq?: number } | null = null
let snapshot: PtyBufferSnapshot | null = null
try {
snapshot = await window.api.pty.getMainBufferSnapshot(currentPtyId, {
snapshot = await serializeHiddenOutputSnapshot(currentPtyId, {
scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
})
} catch {
@ -2268,10 +2390,13 @@ export function connectPanePty(
return
}
if (!snapshot) {
clearHiddenOutputRestoreState()
writeRestoreUnavailableWarning()
hiddenOutputRestoreNeeded = true
hiddenOutputRestoreFreshSnapshotNeeded = false
hiddenOutputRestoreRetryDeferred = true
scheduleHiddenOutputRestoreDeferredRetry()
return
}
hiddenOutputRestoreDeferredRetryAttempts = 0
applyMainBufferSnapshot(snapshot)
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
hiddenOutputRestoreFreshSnapshotNeeded = false
@ -2295,6 +2420,7 @@ export function connectPanePty(
hiddenOutputRestoreNeeded = true
}
if (
!hiddenOutputRestoreRetryDeferred &&
hiddenOutputRestoreNeeded &&
shouldWritePtyOutputForeground(deps.isVisibleRef.current)
) {
@ -2384,9 +2510,9 @@ export function connectPanePty(
}, 50)
}
}
unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data) => {
unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data, meta) => {
if (!disposed) {
dataCallback(data)
dataCallback(data, meta)
}
})
@ -2998,6 +3124,7 @@ export function connectPanePty(
pendingTerminalBellNotification = false
clearTerminalBellNotificationTimer()
clearReattachIdleAgentCursorResetTimer()
cleanupHiddenOutputRestoreDeferredRetry()
unregisterBacklogRecovery?.()
unregisterBacklogRecovery = null
unregisterDocumentVisibilityRecovery?.()

View File

@ -18,6 +18,14 @@ export type PtyDataMeta = {
rawLength?: number
}
export type PtyBufferSnapshot = {
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
}
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
/** Sidecar subscriptions that observe PTY data without owning the primary
* handler. Used by features that need to react to the live byte stream
@ -323,6 +331,7 @@ export type PtyTransport = {
) => boolean
isConnected: () => boolean
getPtyId: () => string | null
serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise<PtyBufferSnapshot | null>
preserve?: () => void
/** Unregister PTY handlers without killing the process, so a remounted
* pane can reattach to the same running shell. */

View File

@ -1104,7 +1104,7 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onReplayData).toHaveBeenCalledWith('hello')
expect(onConnect).toHaveBeenCalled()
expect(onData).toHaveBeenCalledWith(' world')
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
})
it('forwards input and cleanup through runtime RPC', async () => {

View File

@ -38,6 +38,7 @@ export {
export type {
EagerPtyHandle,
PtyTransport,
PtyBufferSnapshot,
PtyConnectResult,
IpcPtyTransportOptions
} from './pty-dispatcher'

View File

@ -91,6 +91,13 @@ describe('createRemoteRuntimePtyTransport', () => {
)
}
function latestFrameForOpcode(opcode: TerminalStreamOpcode) {
return subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === opcode)
.at(-1)
}
function emitSnapshotFrame(
streamId: number,
opcode:
@ -788,7 +795,10 @@ describe('createRemoteRuntimePtyTransport', () => {
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07'
)
expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07')
expect(onData).toHaveBeenCalledWith(
'beforeafter\x1b]0;. Claude working\x07\x07',
expect.objectContaining({ seq: 1 })
)
await vi.waitFor(() =>
expect(onAgentStatus).toHaveBeenCalledWith({
state: 'working',
@ -821,7 +831,7 @@ describe('createRemoteRuntimePtyTransport', () => {
'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after'
)
expect(onData).toHaveBeenCalledWith('beforeafter')
expect(onData).toHaveBeenCalledWith('beforeafter', expect.objectContaining({ seq: 1 }))
await vi.waitFor(() =>
expect(onAgentStatus).toHaveBeenCalledWith({
state: 'working',
@ -1164,6 +1174,112 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onConnect).toHaveBeenCalled()
})
it('resolves explicit binary snapshot requests without replaying into xterm', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onReplayData = vi.fn()
const onData = vi.fn()
const onConnect = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1'
})
await transport.connect({ url: '', callbacks: { onReplayData, onData, onConnect } })
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const { streamId } = latestSubscribePayload()
emitSnapshot(streamId, 'initial')
expect(onReplayData).toHaveBeenCalledWith('initial')
expect(onConnect).toHaveBeenCalled()
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
const snapshotRequestPayload = snapshotRequestFrame
? decodeTerminalStreamJson<{ requestId?: number; scrollbackRows?: number }>(
snapshotRequestFrame.payload
)
: null
expect(snapshotRequestFrame?.streamId).toBe(streamId)
expect(snapshotRequestPayload).toMatchObject({ requestId: 1, scrollbackRows: 5000 })
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
kind: 'scrollback',
requestId: snapshotRequestPayload?.requestId,
cols: 132,
rows: 43,
seq: 17,
source: 'headless'
})
)
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotChunk,
encodeTerminalStreamText('requested snapshot')
)
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
await expect(snapshotPromise).resolves.toEqual({
data: 'requested snapshot',
cols: 132,
rows: 43,
seq: 17,
source: 'headless'
})
expect(onReplayData).toHaveBeenCalledTimes(1)
expect(onData).not.toHaveBeenCalledWith('requested snapshot', expect.anything())
})
it('keeps initial replay separate from in-flight explicit binary snapshot requests', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onReplayData = vi.fn()
const onConnect = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1'
})
await transport.connect({ url: '', callbacks: { onReplayData, onConnect } })
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const { streamId } = latestSubscribePayload()
const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 })
const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)
const snapshotRequestPayload = snapshotRequestFrame
? decodeTerminalStreamJson<{ requestId?: number }>(snapshotRequestFrame.payload)
: null
expect(snapshotRequestPayload?.requestId).toBe(1)
emitSnapshot(streamId, 'initial replay')
expect(onReplayData).toHaveBeenCalledWith('initial replay')
expect(onConnect).toHaveBeenCalled()
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
kind: 'scrollback',
requestId: snapshotRequestPayload?.requestId,
cols: 100,
rows: 20
})
)
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotChunk,
encodeTerminalStreamText('requested replay')
)
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
await expect(snapshotPromise).resolves.toEqual({
data: 'requested replay',
cols: 100,
rows: 20,
seq: undefined,
source: undefined
})
expect(onReplayData).toHaveBeenCalledTimes(1)
})
it('bounds oversized binary snapshots without closing the live stream', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onReplayData = vi.fn()
@ -1194,6 +1310,6 @@ describe('createRemoteRuntimePtyTransport', () => {
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
)
expect(onConnect).toHaveBeenCalled()
expect(onData).toHaveBeenCalledWith('live-after-overflow')
expect(onData).toHaveBeenCalledWith('live-after-overflow', expect.objectContaining({ seq: 1 }))
})
})

View File

@ -310,7 +310,7 @@ export function createRemoteRuntimePtyTransport(
client: { id: clientId, type: 'desktop' },
viewport: desiredViewport ?? undefined,
callbacks: {
onData: (data) => outputProcessor.processData(data, storedCallbacks),
onData: (data, meta) => outputProcessor.processData(data, storedCallbacks, undefined, meta),
onSnapshot: (data) => {
if (data) {
outputProcessor.processData(data, storedCallbacks, {
@ -506,6 +506,13 @@ export function createRemoteRuntimePtyTransport(
return remotePtyId
},
async serializeBuffer(opts) {
if (!connected || !multiplexedStream) {
return null
}
return multiplexedStream.serializeBuffer(opts)
},
destroy() {
destroyed = true
this.disconnect()

View File

@ -624,6 +624,26 @@ describe('pane terminal output scheduler', () => {
expect(terminals[0].write).toHaveBeenCalledTimes(2)
})
it('keeps draining background chunks without per-write parse callback backpressure', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()
const terminal = createTerminal()
const chunk = 'x'.repeat(16 * 1024)
for (let i = 0; i < 6; i++) {
writeTerminalOutput(terminal, chunk, { foreground: false })
}
vi.advanceTimersByTime(50)
vi.advanceTimersByTime(16)
expect(terminal.write).toHaveBeenCalledTimes(4)
vi.advanceTimersByTime(16)
expect(terminal.write).toHaveBeenCalledTimes(6)
})
it('promotes large background backlogs to high-priority drains', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()

View File

@ -3,6 +3,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
decodeTerminalStreamText,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
@ -35,7 +36,7 @@ type TerminalMultiplexEvent =
| { type: string; streamId?: number; [key: string]: unknown }
export type RemoteRuntimeMultiplexedTerminalCallbacks = {
onData: (data: string) => void
onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void
onSnapshot: (data: string) => void
onSubscribed?: () => void
onEnd?: () => void
@ -55,6 +56,13 @@ export type RemoteRuntimeMultiplexedTerminal = {
streamId: number
sendInput: (text: string) => boolean
resize: (cols: number, rows: number) => boolean
serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null>
close: () => void
}
@ -65,10 +73,39 @@ type RemoteRuntimeMultiplexedTerminalState = {
snapshotChunks: Uint8Array<ArrayBufferLike>[]
snapshotBytes: number
snapshotOverflowed: boolean
snapshotTarget: 'initial' | 'request'
snapshotInfo: RemoteRuntimeSnapshotInfo | null
initialSnapshotReceived: boolean
pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null
}
type RemoteRuntimeSnapshotInfo = {
cols?: number
rows?: number
seq?: number
source?: 'headless' | 'renderer'
requestId?: number
truncated?: boolean
}
type RemoteRuntimeSnapshotRequest = {
requestId: number
resolve: (
snapshot: {
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null
) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
const CONTROL_STREAM_ID = 0
const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024
const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE =
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
@ -80,6 +117,7 @@ class RemoteRuntimeTerminalMultiplexer {
private readyRejecter: ((error: Error) => void) | null = null
private ready = false
private nextStreamId = 1
private nextSnapshotRequestId = 1
constructor(
private readonly environmentId: string,
@ -102,7 +140,11 @@ class RemoteRuntimeTerminalMultiplexer {
callbacks: args.callbacks,
snapshotChunks: [],
snapshotBytes: 0,
snapshotOverflowed: false
snapshotOverflowed: false,
snapshotTarget: 'initial',
snapshotInfo: null,
initialSnapshotReceived: false,
pendingSnapshotRequest: null
}
this.streams.set(streamId, state)
@ -116,9 +158,11 @@ class RemoteRuntimeTerminalMultiplexer {
TerminalStreamOpcode.Resize,
encodeTerminalStreamJson({ cols, rows })
),
serializeBuffer: (opts) => this.requestSnapshot(state, opts),
close: () => {
if (this.streams.get(streamId) === state) {
this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe)
rejectPendingSnapshotRequest(state, 'Remote terminal stream closed.')
this.streams.delete(streamId)
this.closeIfIdle()
}
@ -240,11 +284,16 @@ class RemoteRuntimeTerminalMultiplexer {
}
if (event.type === 'end') {
clearSnapshot(stream)
rejectPendingSnapshotRequest(stream, 'Remote terminal stream ended.')
this.streams.delete(event.streamId)
stream.callbacks.onEnd?.()
this.closeIfIdle()
} else if (event.type === 'error') {
clearSnapshot(stream)
rejectPendingSnapshotRequest(
stream,
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
)
stream.callbacks.onError?.(
typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.'
)
@ -279,11 +328,22 @@ class RemoteRuntimeTerminalMultiplexer {
return
}
if (frame.opcode === TerminalStreamOpcode.Output) {
stream.callbacks.onData(decodeTerminalStreamText(frame.payload))
const data = decodeTerminalStreamText(frame.payload)
stream.callbacks.onData(data, {
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
rawLength: data.length
})
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
clearSnapshot(stream)
stream.snapshotInfo = decodeSnapshotInfo(frame.payload)
const requestId = stream.snapshotInfo?.requestId
stream.snapshotTarget =
typeof requestId === 'number' ||
(stream.initialSnapshotReceived && stream.pendingSnapshotRequest)
? 'request'
: 'initial'
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
@ -292,28 +352,112 @@ class RemoteRuntimeTerminalMultiplexer {
}
stream.snapshotBytes += frame.payload.byteLength
if (stream.snapshotBytes > MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES) {
clearSnapshot(stream)
stream.snapshotOverflowed = true
stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE)
if (stream.snapshotTarget === 'initial') {
stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE)
}
return
}
stream.snapshotChunks.push(frame.payload)
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
if (!stream.snapshotOverflowed) {
stream.callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(stream.snapshotChunks)))
const data = stream.snapshotOverflowed
? null
: decodeTerminalStreamText(concatBytes(stream.snapshotChunks))
const target = stream.snapshotTarget
const info = stream.snapshotInfo
const pendingRequest = stream.pendingSnapshotRequest
const matchesPendingRequest =
target === 'request' &&
pendingRequest &&
(typeof info?.requestId === 'number'
? info.requestId === pendingRequest.requestId
: stream.initialSnapshotReceived)
if (!stream.snapshotOverflowed && info?.truncated !== true) {
if (matchesPendingRequest) {
pendingRequest.resolve({
data: data ?? '',
cols: info?.cols ?? 80,
rows: info?.rows ?? 24,
seq: info?.seq,
source: info?.source
})
clearPendingSnapshotRequest(stream)
} else if (target === 'initial') {
stream.callbacks.onSnapshot(data ?? '')
}
} else if (matchesPendingRequest) {
pendingRequest.resolve(null)
clearPendingSnapshotRequest(stream)
}
clearSnapshot(stream)
stream.callbacks.onSubscribed?.()
if (target === 'initial') {
stream.initialSnapshotReceived = true
stream.callbacks.onSubscribed?.()
}
return
}
if (frame.opcode === TerminalStreamOpcode.Error) {
clearSnapshot(stream)
const pendingSnapshotRequest = stream.pendingSnapshotRequest
if (pendingSnapshotRequest) {
clearPendingSnapshotRequest(stream)
pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload)))
return
}
stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload))
}
}
private requestSnapshot(
stream: RemoteRuntimeMultiplexedTerminalState,
opts?: { scrollbackRows?: number }
): Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
} | null> {
if (this.streams.get(stream.streamId) !== stream || !this.ready || !this.subscription) {
return Promise.resolve(null)
}
if (stream.pendingSnapshotRequest) {
return Promise.reject(new Error('Remote terminal snapshot already in flight.'))
}
const requestId = this.allocateSnapshotRequestId()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (stream.pendingSnapshotRequest?.timer === timer) {
stream.pendingSnapshotRequest = null
reject(new Error('Remote terminal snapshot timed out.'))
}
}, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS)
if (typeof timer.unref === 'function') {
timer.unref()
}
stream.pendingSnapshotRequest = { requestId, resolve, reject, timer }
if (
!this.sendFrame(
stream.streamId,
TerminalStreamOpcode.SnapshotRequest,
encodeTerminalStreamJson({ requestId, scrollbackRows: opts?.scrollbackRows })
)
) {
clearPendingSnapshotRequest(stream)
resolve(null)
}
})
}
private allocateSnapshotRequestId(): number {
const id = this.nextSnapshotRequestId
this.nextSnapshotRequestId =
this.nextSnapshotRequestId >= 0x7fffffff ? 1 : this.nextSnapshotRequestId + 1
return id
}
private sendFrame(
streamId: number,
opcode: TerminalStreamOpcode,
@ -357,6 +501,7 @@ class RemoteRuntimeTerminalMultiplexer {
this.streams.clear()
for (const stream of streams) {
clearSnapshot(stream)
rejectPendingSnapshotRequest(stream, message ?? 'Remote runtime connection closed.')
const canHandleClose = Boolean(stream.callbacks.onTransportClose)
stream.callbacks.onTransportClose?.()
if (message && !canHandleClose) {
@ -429,6 +574,52 @@ function clearSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void {
stream.snapshotChunks = []
stream.snapshotBytes = 0
stream.snapshotOverflowed = false
stream.snapshotTarget = 'initial'
stream.snapshotInfo = null
}
function clearPendingSnapshotRequest(stream: RemoteRuntimeMultiplexedTerminalState): void {
const request = stream.pendingSnapshotRequest
stream.pendingSnapshotRequest = null
if (request) {
clearTimeout(request.timer)
}
}
function rejectPendingSnapshotRequest(
stream: RemoteRuntimeMultiplexedTerminalState,
message: string
): void {
const request = stream.pendingSnapshotRequest
if (!request) {
return
}
clearPendingSnapshotRequest(stream)
request.reject(new Error(message))
}
function decodeSnapshotInfo(
payload: Uint8Array<ArrayBufferLike>
): RemoteRuntimeSnapshotInfo | null {
const raw = decodeTerminalStreamJson<{
cols?: unknown
rows?: unknown
seq?: unknown
source?: unknown
requestId?: unknown
truncated?: unknown
}>(payload)
if (!raw) {
return null
}
return {
cols: typeof raw.cols === 'number' ? raw.cols : undefined,
rows: typeof raw.rows === 'number' ? raw.rows : undefined,
seq: typeof raw.seq === 'number' ? raw.seq : undefined,
source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined,
requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined,
truncated: raw.truncated === true
}
}
function isTerminalDriverState(

View File

@ -71,7 +71,7 @@ export async function subscribeToRuntimeTerminalData(
terminal,
client: { id: clientId, type: 'desktop' },
callbacks: {
onData: watcher,
onData: (data) => watcher(data),
onSnapshot: watcher
}
})

View File

@ -19,7 +19,7 @@
export const RUNTIME_PROTOCOL_VERSION = 3
export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3
export const RUNTIME_CAPABILITIES = [
'runtime.status.compat.v1',

View File

@ -68,7 +68,7 @@ describe('terminal-stream-protocol', () => {
expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 })
})
it('round-trips multiplex subscribe and unsubscribe frames', () => {
it('round-trips multiplex subscribe, snapshot request, and unsubscribe frames', () => {
const subscribe = decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
@ -89,12 +89,22 @@ describe('terminal-stream-protocol', () => {
payload: new Uint8Array()
})
)
const snapshotRequest = decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotRequest,
streamId: 12,
seq: 3,
payload: new Uint8Array()
})
)
expect(subscribe?.opcode).toBe(TerminalStreamOpcode.Subscribe)
expect(subscribe && decodeTerminalStreamJson(subscribe.payload)).toMatchObject({
streamId: 12,
terminal: 'terminal-1'
})
expect(snapshotRequest?.opcode).toBe(TerminalStreamOpcode.SnapshotRequest)
expect(snapshotRequest?.streamId).toBe(12)
expect(unsubscribe?.opcode).toBe(TerminalStreamOpcode.Unsubscribe)
expect(unsubscribe?.streamId).toBe(12)
})

View File

@ -12,7 +12,8 @@ export enum TerminalStreamOpcode {
Input = 7,
Resize = 8,
Subscribe = 9,
Unsubscribe = 10
Unsubscribe = 10,
SnapshotRequest = 11
}
export type TerminalStreamFrame = {
@ -90,6 +91,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Subscribe ||
value === TerminalStreamOpcode.Unsubscribe
value === TerminalStreamOpcode.Unsubscribe ||
value === TerminalStreamOpcode.SnapshotRequest
)
}

View File

@ -0,0 +1,305 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveWorktreeId,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
type HiddenTuiWindow = Window & {
__terminalPtyDataInjection?: {
inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean
}
__terminalPtyOutputDebug?: {
reset: () => void
snapshot: () => {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
hiddenRendererMode2031ReplyCount: number
}
}
__terminalHiddenSnapshotOverride?: {
setPending: (
ptyId: string,
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
) => void
resolve: (ptyId: string) => void
clear: (ptyId: string) => void
}
}
type HiddenTuiDebugSnapshot = {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
hiddenRendererMode2031ReplyCount: number
}
function tuiFrame(runId: string, frame: number): string {
const rows = [
`OpenCode visual restore ${runId}`,
`Frame ${String(frame).padStart(3, '0')}`,
`Status ${frame % 2 === 0 ? 'thinking' : 'streaming'}`,
`Input echo ${'#'.repeat((frame % 18) + 1)}`,
`Diff +${frame * 3} -${frame}`,
`VISUAL_RESTORE_FINAL_${runId}_${frame}`
]
return [
'\x1b[?2026h',
'\x1b[?1049h',
'\x1b[2J\x1b[H',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[?2026l'
].join('')
}
async function resetHiddenDebug(page: Page): Promise<void> {
await page.evaluate(() => {
;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset()
})
}
function writeHiddenFrameScript(scriptPath: string, runId: string): void {
const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame))
writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`)
}
async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise<void> {
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
}
async function readHiddenDebug(page: Page): Promise<HiddenTuiDebugSnapshot | null> {
return page.evaluate(() => {
return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null
})
}
async function injectPaneData(
page: Page,
paneKey: string,
data: string,
meta?: { seq?: number; rawLength?: number }
): Promise<void> {
const injected = await page.evaluate(
({ paneKey, data, meta }) => {
return (window as HiddenTuiWindow).__terminalPtyDataInjection?.inject(paneKey, data, meta)
},
{ paneKey, data, meta }
)
if (!injected) {
throw new Error(`No terminal PTY data injector registered for ${paneKey}`)
}
}
async function installDelayedMainSnapshot(
page: Page,
ptyId: string,
snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' }
): Promise<void> {
await page.evaluate(
({ ptyId, snapshot }) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot)
},
{ ptyId, snapshot }
)
}
async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
await page.evaluate((ptyId) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId)
}, ptyId)
}
async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise<void> {
await page.evaluate((ptyId) => {
;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.clear(ptyId)
}, ptyId)
}
async function readMainSnapshotSource(
page: Page,
ptyId: string
): Promise<'headless' | 'renderer' | null> {
return page.evaluate(async (ptyId) => {
const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, {
scrollbackRows: 200
})
return snapshot?.source ?? null
}, ptyId)
}
test.describe('Hidden terminal TUI visual restore', () => {
test('restores skipped hidden full-screen TUI output without visible corruption', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
const hiddenPane = hiddenSnapshot.panes[0]
if (!hiddenPane?.ptyId) {
throw new Error('hidden visual restore pane did not bind a PTY')
}
await switchToWorktree(orcaPage, firstWorktreeId)
await expect
.poll(() => getActiveWorktreeId(orcaPage), {
timeout: 10_000,
message: 'first worktree did not become active before hidden TUI injection'
})
.toBe(firstWorktreeId)
const runId = randomUUID()
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24`
const scriptPath = path.join(testRepoPath, `.orca-hidden-tui-visual-${runId}.mjs`)
writeHiddenFrameScript(scriptPath, runId)
await resetHiddenDebug(orcaPage)
await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath)
await expect
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
timeout: 10_000,
message: 'hidden TUI output did not exercise the skipped-renderer path'
})
.toBeGreaterThan(0)
await expect
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
timeout: 10_000,
message: 'hidden TUI restore did not use the runtime headless snapshot'
})
.toBe('headless')
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'hidden TUI final frame did not restore when the workspace became visible'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame 024`)
expect(content).not.toContain('Orca skipped hidden terminal output')
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
rmSync(scriptPath, { force: true })
})
test('keeps newer live TUI output visually correct while hidden restore is in flight', async ({
orcaPage
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
const hiddenPane = hiddenSnapshot.panes[0]
if (!hiddenPane?.ptyId) {
throw new Error('hidden visual restore pane did not bind a PTY')
}
const paneKey = `${hiddenSnapshot.tabId}:${hiddenPane.leafId}`
await switchToWorktree(orcaPage, firstWorktreeId)
await expect
.poll(() => getActiveWorktreeId(orcaPage), {
timeout: 10_000,
message: 'first worktree did not become active before hidden TUI injection'
})
.toBe(firstWorktreeId)
const runId = randomUUID()
const hiddenFrame = tuiFrame(runId, 40)
const liveFrame = tuiFrame(runId, 41)
const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41`
await resetHiddenDebug(orcaPage)
await injectPaneData(orcaPage, paneKey, hiddenFrame, {
seq: hiddenFrame.length,
rawLength: hiddenFrame.length
})
await expect
.poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, {
timeout: 10_000,
message: 'hidden injected TUI output did not skip renderer parsing'
})
.toBeGreaterThan(0)
await installDelayedMainSnapshot(orcaPage, hiddenPane.ptyId, {
data: hiddenFrame,
cols: 120,
rows: 40,
seq: hiddenFrame.length,
source: 'headless'
})
try {
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await injectPaneData(orcaPage, paneKey, liveFrame, {
seq: hiddenFrame.length + liveFrame.length,
rawLength: liveFrame.length
})
await resolveDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'newer live TUI frame did not render after delayed hidden snapshot'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain('Frame 041')
expect(content).not.toContain('Frame 040')
expect(content).not.toContain('Orca skipped hidden terminal output')
const screenshotPath = testInfo.outputPath('hidden-tui-delayed-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-delayed-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
await clearDelayedMainSnapshot(orcaPage, hiddenPane.ptyId)
}
})
})