mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(terminal): never wipe a painted pane for a snapshot that carries no image (#14099)
* fix(terminal): never wipe a painted pane for a snapshot that carries no image
A hidden-output restore that comes back as a success frame with an empty image
was still applied. The normal-buffer replay opens with \x1b[2J\x1b[3J\x1b[H, so
it cleared screen and scrollback and painted nothing in their place -- turning a
host that had nothing to say into a blank pane.
An empty image is the absence of an answer, not proof the pane is empty, so keep
the frame the user can still see. Retry and banner behavior are unchanged: an
empty image still counts as a successful, non-bannered recovery.
* test(terminal): prove imageless restore preserves painted buffer
(cherry picked from commit a2c9f7e52b)
This commit is contained in:
@@ -326,6 +326,7 @@ type MockTransport = {
|
||||
getPtyId: ReturnType<typeof vi.fn>
|
||||
getConnectionId: ReturnType<typeof vi.fn>
|
||||
serializeBuffer?: ReturnType<typeof vi.fn>
|
||||
serializeBufferOutcome?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const scheduleRuntimeGraphSync = vi.fn()
|
||||
@@ -15169,6 +15170,63 @@ describe('connectPanePty', () => {
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('preserves the painted normal buffer when a successful hidden restore has no image', 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 }
|
||||
const hidden = 'x'.repeat(2 * 1024 * 1024 + 1)
|
||||
const live = 'visible remote output\r\n'
|
||||
transport.serializeBuffer = vi.fn()
|
||||
transport.serializeBufferOutcome = vi.fn().mockResolvedValue({
|
||||
availability: { kind: 'snapshot' },
|
||||
snapshot: {
|
||||
data: '',
|
||||
scrollbackAnsi: '',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: hidden.length + live.length,
|
||||
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 deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
const writeStart = pane.terminal.write.mock.calls.length
|
||||
|
||||
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
|
||||
;(deps.isVisibleRef as { current: boolean }).current = true
|
||||
capturedDataCallback.current?.(live, {
|
||||
seq: hidden.length + live.length,
|
||||
rawLength: live.length
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const restoreWrites = pane.terminal.write.mock.calls
|
||||
.slice(writeStart)
|
||||
.map(([data]) => String(data))
|
||||
const paintedFrame = Array.from({ length: 8 }, (_, index) => `painted-frame-${index + 1}`).join(
|
||||
'\r\n'
|
||||
)
|
||||
const actual = await renderHeadlessTerminalState([paintedFrame, ...restoreWrites], 120, 4)
|
||||
const expected = await renderHeadlessTerminalState(
|
||||
[paintedFrame, ...restoreWrites.filter((data) => data === live)],
|
||||
120,
|
||||
4
|
||||
)
|
||||
expect(actual).toEqual(expected)
|
||||
expect(transport.serializeBufferOutcome).toHaveBeenCalledTimes(1)
|
||||
expect(transport.serializeBuffer).not.toHaveBeenCalled()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('defers inactive split-pane plain hidden output restore until the pane returns', async () => {
|
||||
const { resetHiddenOutputRestoreSchedulerForTests } =
|
||||
await import('./hidden-output-restore-scheduler')
|
||||
|
||||
@@ -7300,10 +7300,15 @@ export function connectPanePty(
|
||||
snapshot.alternateScreen === true &&
|
||||
snapshot.frameRestoreAnsi !== undefined &&
|
||||
shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane))
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
skipAltFrame: skippedAltFrame
|
||||
})) {
|
||||
writeReplayData(replayChunk)
|
||||
// Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback.
|
||||
const snapshotCarriesNoImage =
|
||||
snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi
|
||||
if (!snapshotCarriesNoImage) {
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
skipAltFrame: skippedAltFrame
|
||||
})) {
|
||||
writeReplayData(replayChunk)
|
||||
}
|
||||
}
|
||||
// Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup).
|
||||
writeReplayData(
|
||||
|
||||
Reference in New Issue
Block a user