From e9646e648f719c8422b940add568b5ee0ed64166 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:09:04 -0700 Subject: [PATCH] fix(terminal): preserve parser carry on empty snapshots --- src/main/runtime/rpc/methods/terminal.ts | 13 ++---- ...ultiplex-initial-snapshot-fallback.test.ts | 45 ++++++++++++++++++- ...-requested-snapshot-unavailability.test.ts | 45 +++++++++++++++---- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 0bf4a936673..e18da48caf8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2349,13 +2349,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ source: serializedContent?.source, kittyKeyboardFlags: serializedContent?.kittyKeyboardFlags, oscLinks: serializedContent?.oscLinks, - pendingEscapeTailAnsi: serializedContent?.pendingEscapeTailAnsi, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, - // Why: `unavailable` means nobody answered, so the client retries and can - // eventually banner. A serializer that DID answer with an empty buffer has - // answered — reporting that as unavailable banners genuinely empty panes. - // Empty content still suppresses the seq/source metadata below. + // Why: only absence is retry-worthy; empty visual data still suppresses provenance. unavailable: serialized ? undefined : 'no-serializable-buffer', data: serializedContent?.data ?? '' }) @@ -2696,10 +2693,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ source: serializedContent?.source, kittyKeyboardFlags: serializedContent?.kittyKeyboardFlags, oscLinks: serializedContent?.oscLinks, - pendingEscapeTailAnsi: serializedContent?.pendingEscapeTailAnsi, - // Why: same rule as the tagged path — only an absent serializer answer is - // "unavailable". A serializer that answered empty, with an empty retained - // tail, is a genuinely empty pane and must not be reported as a failure. + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + // Why: only absence without retained tail is retry-worthy; empty answers are valid. unavailable: serialized || read.tail.length > 0 ? undefined : 'no-serializable-buffer', data: diff --git a/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-fallback.test.ts b/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-fallback.test.ts index 652c8ebe122..96e7b7b8441 100644 --- a/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-fallback.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-fallback.test.ts @@ -20,6 +20,8 @@ type SerializedBuffer = { rows: number seq?: number source?: 'headless' | 'renderer' + cwd?: string + pendingEscapeTailAnsi?: string } | null function makeRequest(method: string, params?: unknown): RpcRequest { @@ -121,7 +123,14 @@ describe('terminal multiplex initial snapshot fallback', () => { it('serves the retained tail when serialization returns an empty snapshot object', async () => { const snapshot = await subscribeSnapshot({ connectionId: 'conn-empty-snapshot-tail', - serialized: { data: '', cols: 120, rows: 40, seq: 42, source: 'renderer' }, + serialized: { + data: '', + cols: 120, + rows: 40, + seq: 42, + source: 'renderer', + cwd: '/stale' + }, tail: ['retained line 1', 'retained line 2'] }) @@ -129,6 +138,7 @@ describe('terminal multiplex initial snapshot fallback', () => { expect(snapshot.start.unavailable).toBeUndefined() expect(snapshot.start.seq).toBeUndefined() expect(snapshot.start.source).toBeUndefined() + expect(snapshot.start.cwd).toBeUndefined() }) it('keeps the retained-tail fallback when serialization returns null', async () => { @@ -165,4 +175,37 @@ describe('terminal multiplex initial snapshot fallback', () => { expect(snapshot.data).toBe('') expect(snapshot.start.unavailable).toBe('no-serializable-buffer') }) + + it('reports a successful empty snapshot when the serializer answered and no tail exists', async () => { + const snapshot = await subscribeSnapshot({ + connectionId: 'conn-empty-snapshot-no-tail', + serialized: { data: '', cols: 120, rows: 40, seq: 42, source: 'renderer' }, + tail: [] + }) + + expect(snapshot.data).toBe('') + expect(snapshot.start.unavailable).toBeUndefined() + expect(snapshot.start.seq).toBeUndefined() + expect(snapshot.start.source).toBeUndefined() + }) + + it('preserves parser carry state when serialized visual data is empty', async () => { + const snapshot = await subscribeSnapshot({ + connectionId: 'conn-empty-snapshot-parser-tail', + serialized: { + data: '', + cols: 120, + rows: 40, + seq: 42, + source: 'renderer', + pendingEscapeTailAnsi: '\x1b[38;2;255' + }, + tail: [] + }) + + expect(snapshot.data).toBe('') + expect(snapshot.start.pendingEscapeTailAnsi).toBe('\x1b[38;2;255') + expect(snapshot.start.seq).toBeUndefined() + expect(snapshot.start.source).toBeUndefined() + }) }) diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index f86be1586d5..c3e6d4023f4 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -13,7 +13,16 @@ import { encodeTerminalStreamJson } from '../../../shared/terminal-stream-protocol' -type SerializedBuffer = { data: string; scrollbackAnsi?: string; cols: number; rows: number } | null +type SerializedBuffer = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string + source?: 'headless' | 'renderer' + pendingEscapeTailAnsi?: string +} | null type SnapshotStartPayload = Record // Why: 256 KiB is the pending-output budget, so this many 1 KiB chunks always trips the overflow guard. @@ -168,9 +177,7 @@ describe('requested terminal snapshot unavailability reasons', () => { expect(start.unavailable).toBeUndefined() }) - // Why: `unavailable` drives client retries and eventually a loss banner. A serializer - // that answered with an empty buffer HAS answered; a genuinely empty pane must not be - // reported as a failure or every fresh pane banners. Only an absent answer is a failure. + // Why: unavailable answers retry and eventually banner; empty answers are valid. it('does not report a reason when the serializer answered with an empty buffer', async () => { const { start, chunks } = await requestSnapshotReply({ connectionId: 'conn-reason-empty', @@ -181,15 +188,37 @@ describe('requested terminal snapshot unavailability reasons', () => { expect(start.unavailable).toBeUndefined() }) - // Why: an empty answer must still not publish seq/source metadata, or the client - // anchors its stream to a snapshot that carries no content. - it('omits seq and source metadata for an empty serialized answer', async () => { + // Why: empty visual data cannot anchor stream provenance. + it('omits seq, source, and cwd metadata for an empty serialized answer', async () => { const { start } = await requestSnapshotReply({ connectionId: 'conn-reason-empty-metadata', - serializeRequested: async () => ({ data: '', cols: 120, rows: 40 }) + serializeRequested: async () => ({ + data: '', + cols: 120, + rows: 40, + seq: 42, + source: 'renderer', + cwd: '/stale' + }) }) expect(start.seq).toBeUndefined() expect(start.source).toBeUndefined() + expect(start.cwd).toBeUndefined() + }) + + it('preserves parser carry state when serialized visual data is empty', async () => { + const { start, chunks } = await requestSnapshotReply({ + connectionId: 'conn-reason-empty-parser-tail', + serializeRequested: async () => ({ + data: '', + cols: 120, + rows: 40, + pendingEscapeTailAnsi: '\x1b[38;2;255' + }) + }) + expect(chunks).toBe('') + expect(start.pendingEscapeTailAnsi).toBe('\x1b[38;2;255') + expect(start.unavailable).toBeUndefined() }) it('accepts scrollback-only serialized content', async () => {