diff --git a/src/renderer/src/lib/prompt-echo-wait.ts b/src/renderer/src/lib/prompt-echo-wait.ts index 366f69d3ff4..6a1d00f54de 100644 --- a/src/renderer/src/lib/prompt-echo-wait.ts +++ b/src/renderer/src/lib/prompt-echo-wait.ts @@ -52,11 +52,16 @@ export function watchForPromptEcho( } if (isRemoteRuntimePtyId(ptyId)) { + // Why: skip the initial buffer snapshot — it is the pre-paste screen and + // could match the sample (or a stale paste placeholder) before the echo, + // falsely verifying delivery onto a swallow screen (#7466). Only output + // rendered after we subscribe counts, matching the local sidecar. void subscribeToRuntimeTerminalData( settings, ptyId, `desktop:prompt-echo:${ptyId}`, - observeData + observeData, + { includeSnapshot: false } ) .then((remoteUnsubscribe) => { if (settled) { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..60ab65140bf 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -4,6 +4,7 @@ import { decodeTerminalStreamFrame, decodeTerminalStreamJson, encodeTerminalStreamFrame, + encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { @@ -117,6 +118,81 @@ describe('remote runtime terminal data subscriptions', () => { expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) + async function currentStreamId(): Promise { + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) + return decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame!.payload)!.streamId + } + + function deliverInitialSnapshot(streamId: number, text: string): void { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ cols: 80, rows: 24 }) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 0, + payload: encodeTerminalStreamText(text) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 0, + payload: new Uint8Array(0) + }) + ) + } + + function deliverOutput(streamId: number, text: string): void { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 1, + payload: encodeTerminalStreamText(text) + }) + ) + } + + it('replays the initial buffer snapshot to the watcher by default', async () => { + const watcher = vi.fn() + await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher + ) + deliverInitialSnapshot(await currentStreamId(), 'PRE-PASTE SCREEN') + + expect(watcher.mock.calls.map((call) => call[0])).toContain('PRE-PASTE SCREEN') + }) + + it('omits the initial snapshot from the watcher when includeSnapshot is false', async () => { + const watcher = vi.fn() + await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher, + { includeSnapshot: false } + ) + const streamId = await currentStreamId() + deliverInitialSnapshot(streamId, 'PRE-PASTE SCREEN') + // Live output rendered after the subscription still reaches the watcher. + deliverOutput(streamId, 'live') + + expect(watcher).not.toHaveBeenCalledWith('PRE-PASTE SCREEN') + expect(watcher).toHaveBeenCalledWith('live') + }) + it('keeps the shared terminal multiplexer until the last watcher closes', async () => { const firstDispose = await subscribeToRuntimeTerminalData( { activeRuntimeEnvironmentId: 'env-fallback' }, diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 7dbdf3eb002..3def06fda5b 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -56,7 +56,8 @@ export async function subscribeToRuntimeTerminalData( settings: Pick | null | undefined, ptyId: string, clientId: string, - watcher: (data: string) => void + watcher: (data: string) => void, + options?: { includeSnapshot?: boolean } ): Promise<() => void> { const terminal = getRemoteRuntimeTerminalHandle(ptyId) const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) @@ -67,12 +68,17 @@ export async function subscribeToRuntimeTerminalData( return () => {} } + // Why: the initial buffer snapshot is the screen state from *before* this + // subscription attached. Consumers that must only judge freshly-rendered + // output (prompt-echo verification) opt out so a pre-paste screen can't be + // mistaken for the paste echoing back (#7466); the default replays it. + const includeSnapshot = options?.includeSnapshot ?? true const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({ terminal, client: { id: clientId, type: 'desktop' }, callbacks: { onData: (data) => watcher(data), - onSnapshot: watcher + onSnapshot: includeSnapshot ? watcher : () => {} } })