From bede7702b91d0d27342bcba0230996ac63bacaca Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Wed, 8 Jul 2026 15:20:55 -0700 Subject: [PATCH] fix(agents): verify prompt echo before submitting deferred launch pastes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readiness heuristics can fire on codex trust/update screens (and similar login/error states in other agents) that silently swallow the paste — the launch then reported delivered:true, resolved PR comments, and discarded the prompt (#7466), and the blind submit Enter could even answer the swallowing screen (live-reproduced confirming codex's self-update). Deferred launch submits are now two-phase: paste, watch the PTY sidecar stream for the pasted content (or the agent's paste placeholder) to visibly render, and only then send Enter. No echo -> no Enter -> the existing delivery-failure recovery keeps the prompt retryable. The echo scanner folds output to alphanumerics because TUIs place words with cursor moves instead of literal spaces (verified against raw PTY captures from codex 0.143.0 and claude, including the trust-screen swallow). Failures are classified as paste_echo_timeout in telemetry. Co-authored-by: Orca --- .../lib/agent-launch-prompt-delivery.test.ts | 15 ++ .../src/lib/agent-launch-prompt-delivery.ts | 6 +- ...gent-paste-draft-echo-verification.test.ts | 187 ++++++++++++++++++ src/renderer/src/lib/agent-paste-draft.ts | 47 ++++- .../src/lib/launch-agent-in-new-tab.test.ts | 29 +++ .../src/lib/launch-agent-in-new-tab.ts | 4 +- src/renderer/src/lib/prompt-echo-wait.ts | 78 ++++++++ src/shared/prompt-echo-scanner.test.ts | 93 +++++++++ src/shared/prompt-echo-scanner.ts | 91 +++++++++ src/shared/telemetry-events.ts | 10 +- 10 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/agent-paste-draft-echo-verification.test.ts create mode 100644 src/renderer/src/lib/prompt-echo-wait.ts create mode 100644 src/shared/prompt-echo-scanner.test.ts create mode 100644 src/shared/prompt-echo-scanner.ts diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 577db1e534e..aaf9954bdc1 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -50,11 +50,26 @@ describe('deliverLaunchPromptToAgentTab', () => { content: 'Fix failing checks', submit: true, forcePaste: true, + verifyEchoBeforeSubmit: true, timeoutMs: undefined, onTimeout: undefined }) }) + it('does not require echo verification for unsubmitted drafts', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Draft notes', + submit: false, + forcePaste: true + }) + + expect(mocks.pasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ submit: false, verifyEchoBeforeSubmit: false }) + ) + }) + it('does not seed for drafts, unsupported agents, or empty content', async () => { await deliverLaunchPromptToAgentTab({ tabId: 'draft-tab', diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 1569c5f1a92..8b614ba9c81 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -1,5 +1,6 @@ import { agentDeliversDraftViaNativePrefill } from '@/lib/agent-native-draft-prefill' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import type { PasteDeliveryTimeoutReason } from '@/lib/agent-paste-draft' import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/types' @@ -11,7 +12,7 @@ export function deliverLaunchPromptToAgentTab(args: { submit: boolean forcePaste: boolean timeoutMs?: number - onTimeout?: () => void + onTimeout?: (reason?: PasteDeliveryTimeoutReason) => void }): Promise { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout } = args const shouldSeed = @@ -37,6 +38,9 @@ export function deliverLaunchPromptToAgentTab(args: { agent, submit, forcePaste, + // Why: launch prompts drive irreversible side effects (comment resolution, + // dialog dismissal), so their submit must be echo-verified (issue #7466). + verifyEchoBeforeSubmit: submit, timeoutMs, onTimeout }).then((delivered) => { diff --git a/src/renderer/src/lib/agent-paste-draft-echo-verification.test.ts b/src/renderer/src/lib/agent-paste-draft-echo-verification.test.ts new file mode 100644 index 00000000000..38e865eaa98 --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft-echo-verification.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pasteDraftWhenAgentReady } from './agent-paste-draft' + +const testState = vi.hoisted(() => ({ + appState: { + settings: {}, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {} as Record, + repos: [] as { id: string; connectionId: string | null }[], + worktreesByRepo: {} as Record + }, + ptyObserver: null as ((data: string) => void) | null, + unsubscribe: vi.fn(), + subscribeToPtyData: vi.fn(), + isRemoteRuntimePtyId: vi.fn(), + sendRuntimePtyInputVerified: vi.fn(), + inspectRuntimeTerminalProcess: vi.fn(), + subscribeToRuntimeTerminalData: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => testState.appState } +})) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: testState.subscribeToPtyData +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, + sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess +})) +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData +})) + +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const PROMPT = + 'Resolve the current merge conflicts UNIQUEMARKER7466 and report the final git status.' +// Real codex 0.143.0 composer echo: every word placed by a CSI cursor move. +const CODEX_VERBATIM_ECHO = + '\x1b[11;3H\x1b[22mResolve\x1b[11;11Hthe\x1b[11;15Hcurrent\x1b[11;23Hmerge\x1b[11;29Hconflicts\x1b[11;39HUNIQUEMARKER7466' +// Real codex trust-screen redraw that swallowed a paste: clears rows, renders +// none of the pasted characters. +const TRUST_SCREEN_REDRAW = '\x1b[1;58H\x1b[0m\x1b[49m\x1b[K\x1b[2;2H\x1b[0m\x1b[49m\x1b[K' + +async function flushMicrotasks(iterations = 4): Promise { + for (let index = 0; index < iterations; index += 1) { + await Promise.resolve() + } +} + +describe('pasteDraftWhenAgentReady echo verification', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + testState.ptyObserver = null + testState.unsubscribe.mockReset() + testState.subscribeToPtyData.mockReset() + testState.subscribeToPtyData.mockImplementation( + (_ptyId: string, observer: (data: string) => void) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + testState.isRemoteRuntimePtyId.mockReset() + testState.isRemoteRuntimePtyId.mockReturnValue(false) + testState.sendRuntimePtyInputVerified.mockReset() + testState.sendRuntimePtyInputVerified.mockResolvedValue(true) + testState.inspectRuntimeTerminalProcess.mockReset() + testState.inspectRuntimeTerminalProcess.mockResolvedValue(null) + testState.subscribeToRuntimeTerminalData.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + function startVerifiedPaste(onTimeout = vi.fn()): { + promise: Promise + onTimeout: ReturnType + } { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: PROMPT, + agent: 'codex', + submit: true, + forcePaste: true, + verifyEchoBeforeSubmit: true, + onTimeout + }) + return { promise, onTimeout } + } + + it('submits only after the pasted content visibly renders', async () => { + const { promise, onTimeout } = startVerifiedPaste() + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() + + // The paste is written, but the submit Enter is withheld pending the echo. + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + `\x1b[200~${PROMPT}\x1b[201~` + ) + + testState.ptyObserver?.(CODEX_VERBATIM_ECHO) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(50) + + await expect(promise).resolves.toBe(true) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('withholds the Enter and reports echo-timeout when the paste never renders', async () => { + const { promise, onTimeout } = startVerifiedPaste() + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + + // Trust/update screens only redraw; the pasted characters never render. + testState.ptyObserver?.(TRUST_SCREEN_REDRAW) + await vi.advanceTimersByTimeAsync(3000) + + await expect(promise).resolves.toBe(false) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(onTimeout).toHaveBeenCalledWith('echo-timeout') + }) + + it('reports a readiness-timeout reason when the agent never becomes ready', async () => { + const { promise, onTimeout } = startVerifiedPaste() + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(8000) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(1100) + await flushMicrotasks() + + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledWith('readiness-timeout') + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + + it('does not send the Enter when the paste write itself fails', async () => { + testState.sendRuntimePtyInputVerified.mockResolvedValueOnce(false) + const { promise, onTimeout } = startVerifiedPaste() + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() + + await expect(promise).resolves.toBe(false) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('keeps the single-phase flow when verification is not requested', async () => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: PROMPT, + agent: 'codex', + submit: true, + forcePaste: true + }) + await flushMicrotasks() + + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(50) + + await expect(promise).resolves.toBe(true) + // Paste then Enter, with no echo gate in between. + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') + }) +}) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index e9246310881..76edc4811ae 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -16,6 +16,7 @@ import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { watchForPromptEcho } from './prompt-echo-wait' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, @@ -43,6 +44,11 @@ export function sanitizeBracketedPasteContent(content: string): string { // or (2) the launch fails outright. The hard timeout caps the wait so a // stuck launch doesn't pin a Promise forever. const READINESS_TIMEOUT_MS = 8000 +// Why: a live composer repaints the pasted text within one frame; the window +// only needs to absorb remote-PTY round-trips and slow first paints. +const ECHO_VERIFY_TIMEOUT_MS = 3000 + +export type PasteDeliveryTimeoutReason = 'readiness-timeout' | 'echo-timeout' export function getSettingsForAgentTabRuntimeOwner( tabId: string @@ -80,10 +86,20 @@ export async function pasteDraftWhenAgentReady(args: { agent?: TuiAgent submit?: boolean forcePaste?: boolean + verifyEchoBeforeSubmit?: boolean timeoutMs?: number - onTimeout?: () => void + onTimeout?: (reason?: PasteDeliveryTimeoutReason) => void }): Promise { - const { tabId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args + const { + tabId, + content, + agent, + submit, + forcePaste, + verifyEchoBeforeSubmit, + timeoutMs, + onTimeout + } = args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null @@ -100,7 +116,7 @@ export async function pasteDraftWhenAgentReady(args: { const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' const ptyId = await waitForPtyId(tabId, budget) if (!ptyId) { - onTimeout?.() + onTimeout?.('readiness-timeout') return false } @@ -115,7 +131,30 @@ export async function pasteDraftWhenAgentReady(args: { ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) : { ready: false } if (!fallbackReady.ready) { - onTimeout?.() + onTimeout?.('readiness-timeout') + return false + } + } + + if (submit === true && verifyEchoBeforeSubmit === true) { + // Why: readiness heuristics can fire on trust/update/login screens that + // silently swallow pastes (issue #7466). Require the pasted content to + // visibly render before committing the submit Enter — an unverified Enter + // could otherwise answer whatever screen actually has focus. + const watch = watchForPromptEcho(ptyId, content, ECHO_VERIFY_TIMEOUT_MS, settings) + const pasted = await sendBracketedPasteToAgent({ settings, ptyId, content, submit: false }) + if (!pasted) { + watch.cancel() + return false + } + if (!(await watch.result)) { + onTimeout?.('echo-timeout') + return false + } + await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) + try { + return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + } catch { return false } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fd25fc85df6..383b0759e94 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -679,6 +679,35 @@ describe('launchAgentInNewTab', () => { expect(mockToastMessage).toHaveBeenCalledWith( "Your prompt wasn't sent — paste it once the agent is ready." ) + expect(mockTrack).toHaveBeenCalledWith( + 'agent_error', + expect.objectContaining({ error_class: 'paste_readiness_timeout' }) + ) + }) + + it('classifies a swallowed paste (echo timeout) separately in telemetry', async () => { + mockPasteDraftWhenAgentReady.mockImplementation(({ onTimeout }) => { + onTimeout?.('echo-timeout') + return Promise.resolve(false) + }) + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' } as never] } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'command-code', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mockTrack).toHaveBeenCalledWith( + 'agent_error', + expect.objectContaining({ error_class: 'paste_echo_timeout' }) + ) }) it('marks a cancelled submit-after-ready launch notified when the user closed the tab', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 50397347791..ac9dfdf0f1b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -309,7 +309,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, submit: submitPastedPrompt, forcePaste: forcePasteAfterLaunch, - onTimeout: () => { + onTimeout: (reason) => { const state = useAppStore.getState() const tabsForWorktree = state.tabsByWorktree[worktreeId] ?? [] const currentTab = tabsForWorktree.find((t) => t.id === tab.id) @@ -335,7 +335,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ) failureNotified = true track('agent_error', { - error_class: 'paste_readiness_timeout', + error_class: reason === 'echo-timeout' ? 'paste_echo_timeout' : 'paste_readiness_timeout', agent_kind: tuiAgentToAgentKind(agent) }) } diff --git a/src/renderer/src/lib/prompt-echo-wait.ts b/src/renderer/src/lib/prompt-echo-wait.ts new file mode 100644 index 00000000000..366f69d3ff4 --- /dev/null +++ b/src/renderer/src/lib/prompt-echo-wait.ts @@ -0,0 +1,78 @@ +import type { GlobalSettings } from '../../../shared/types' +import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { createPromptEchoScanner } from '../../../shared/prompt-echo-scanner' + +export type PromptEchoWatch = { + /** Resolves true once the pasted content visibly renders; false on timeout/cancel. */ + result: Promise + /** Stop watching early (e.g. the paste write itself failed). */ + cancel: () => void +} + +/** + * Tap the PTY data stream as a sidecar observer and resolve once the pasted + * content (or the agent's paste placeholder) renders. Start the watch BEFORE + * writing the paste so a fast echo cannot slip past the subscription. + * + * Same transport split as waitForAgentDraftInputReady: local PTYs use the + * dispatcher sidecar; remote runtime PTYs subscribe through the host stream. + */ +export function watchForPromptEcho( + ptyId: string, + pastedContent: string, + timeoutMs: number, + settings: Pick | null | undefined +): PromptEchoWatch { + let cancel: () => void = () => {} + const result = new Promise((resolve) => { + let settled = false + const scanner = createPromptEchoScanner(pastedContent) + let hardTimer: number | null = null + let unsubscribe: (() => void) | null = null + + const finish = (value: boolean): void => { + if (settled) { + return + } + settled = true + if (hardTimer !== null) { + window.clearTimeout(hardTimer) + } + unsubscribe?.() + resolve(value) + } + cancel = () => finish(false) + + const observeData = (data: string): void => { + if (scanner.observe(data)) { + finish(true) + } + } + + if (isRemoteRuntimePtyId(ptyId)) { + void subscribeToRuntimeTerminalData( + settings, + ptyId, + `desktop:prompt-echo:${ptyId}`, + observeData + ) + .then((remoteUnsubscribe) => { + if (settled) { + remoteUnsubscribe() + return + } + unsubscribe = remoteUnsubscribe + }) + .catch(() => finish(false)) + } else { + unsubscribe = subscribeToPtyData(ptyId, observeData) + } + + if (!settled) { + hardTimer = window.setTimeout(() => finish(false), timeoutMs) + } + }) + return { result, cancel } +} diff --git a/src/shared/prompt-echo-scanner.test.ts b/src/shared/prompt-echo-scanner.test.ts new file mode 100644 index 00000000000..959f2a19589 --- /dev/null +++ b/src/shared/prompt-echo-scanner.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { createPromptEchoScanner, foldTerminalTextForEchoMatch } from './prompt-echo-scanner' + +const PROMPT = + 'Resolve the current merge conflicts UNIQUEMARKER7466 and report the final git status.' + +// Real PTY excerpts captured 2026-07-08 from codex 0.143.0 and claude on +// macOS (120x40) while bracket-pasting PROMPT (no Enter). Codex positions +// every word with a CSI cursor move — the stream contains no literal spaces. +const CODEX_VERBATIM_ECHO = + '───────────────────╯\x1b[11;1H\x1b[22m\x1b[1m›\x1b[11;3H\x1b[22mResolve\x1b[11;11Hthe\x1b[11;15Hcurrent\x1b[11;23Hmerge\x1b[11;29Hconflicts\x1b[11;39HUNIQUEMARKER7466\x1b[11;56Hand\x1b[11;60Hreport\x1b[11;67Hthe\x1b[11;71Hfinal\x1b[11;77Hgit\x1b[11;81Hstatus.\x1b[13;3H\x1b[38;2;246;226;183;49m' +const CODEX_LARGE_PLACEHOLDER = + ' │\x1b[8;1H╰────────────────────────────────────────────────╯\x1b[11;1H\x1b[22m\x1b[1m›\x1b[11;3H\x1b[22m\x1b[38;5;6;49m[Pasted Content 4295 chars]\x1b[13;3H\x1b[38;2;246;226;183;49mgpt-5.5 default' +const CLAUDE_VERBATIM_ECHO = + '25l\x1b[H\r\x1b[2C\x1b[36BResolve the current merge conflicts\x1b[39GUNIQUEMARKER7466\x1b[56Gand\x1b[60Greport\x1b[67Gthe\x1b[71Gfinal\x1b[77Ggit\x1b[81Gstatus.\x1b[40;1H\x1b[37;88H\x1b[?25h' +const CLAUDE_LARGE_PLACEHOLDER = + '25l\x1b[H\r\x1b[2C\x1b[36B[Pasted text #1 +39 lines]\x1b[K\r\x1b[2C\x1b[3B\x1b[38;2;153;153;153mpaste again to expand' +// Codex's directory-trust screen swallowing the paste: it only redraws +// (clear-to-EOL per row) and never renders any of the pasted characters. +const CODEX_TRUST_SWALLOW_REDRAW = + '\x1b[?2026h\x1b[1;58H\x1b[0m\x1b[49m\x1b[K\x1b[2;2H\x1b[0m\x1b[49m\x1b[K\x1b[3;112H\x1b[0m\x1b[49m\x1b[K\x1b[4;99H\x1b[0m\x1b[49m\x1b[K\x1b[5;2H\x1b[0m\x1b[49m\x1b[K\x1b[6;19H\x1b[0m\x1b[49m\x1b[K\x1b[7;14H\x1b[0m\x1b[49m\x1b[K\x1b[8;2H\x1b[0m\x1b[49m\x1b[K' + +function feedInChunks(data: string, chunkSize: number, content = PROMPT): boolean { + const scanner = createPromptEchoScanner(content) + for (let i = 0; i < data.length; i += chunkSize) { + if (scanner.observe(data.slice(i, i + chunkSize))) { + return true + } + } + return false +} + +describe('foldTerminalTextForEchoMatch', () => { + it('strips escapes, whitespace, and punctuation down to comparable text', () => { + expect(foldTerminalTextForEchoMatch('\x1b[1mRe\x1b[11;3Hsolve │ the-MERGE!')).toBe( + 'resolvethemerge' + ) + }) + + it('drops OSC title sequences entirely', () => { + expect(foldTerminalTextForEchoMatch('\x1b]0;my secret title\x07visible')).toBe('visible') + }) +}) + +describe('createPromptEchoScanner', () => { + it('detects the codex word-positioned verbatim echo', () => { + expect(feedInChunks(CODEX_VERBATIM_ECHO, 4096)).toBe(true) + }) + + it('detects the claude column-positioned verbatim echo', () => { + expect(feedInChunks(CLAUDE_VERBATIM_ECHO, 4096)).toBe(true) + }) + + it('detects the codex large-paste placeholder', () => { + expect(feedInChunks(CODEX_LARGE_PLACEHOLDER, 4096)).toBe(true) + }) + + it('detects the claude large-paste placeholder', () => { + expect(feedInChunks(CLAUDE_LARGE_PLACEHOLDER, 4096)).toBe(true) + }) + + it('does not fire on the codex trust-screen redraw that swallowed the paste', () => { + expect(feedInChunks(CODEX_TRUST_SWALLOW_REDRAW, 4096)).toBe(false) + }) + + it('matches across chunk seams that split words and escape sequences', () => { + for (const chunkSize of [1, 3, 7]) { + expect(feedInChunks(CODEX_VERBATIM_ECHO, chunkSize)).toBe(true) + expect(feedInChunks(CODEX_TRUST_SWALLOW_REDRAW, chunkSize)).toBe(false) + } + }) + + it('matches the tail sample when only the end of the content renders', () => { + const scanner = createPromptEchoScanner(PROMPT) + expect(scanner.observe('…\x1b[11;3Hand report the final git status.')).toBe(true) + }) + + it('latches once echoed', () => { + const scanner = createPromptEchoScanner(PROMPT) + expect(scanner.observe(CODEX_VERBATIM_ECHO)).toBe(true) + expect(scanner.observe('unrelated')).toBe(true) + }) + + it('treats content that folds below the minimum sample as echoed on any render', () => { + const scanner = createPromptEchoScanner('!?— \n') + expect(scanner.observe('any repaint at all')).toBe(true) + }) + + it('does not fire before any output arrives for unmatchable content', () => { + const scanner = createPromptEchoScanner('!?— \n') + expect(scanner.observe('')).toBe(false) + }) +}) diff --git a/src/shared/prompt-echo-scanner.ts b/src/shared/prompt-echo-scanner.ts new file mode 100644 index 00000000000..4f18b888b61 --- /dev/null +++ b/src/shared/prompt-echo-scanner.ts @@ -0,0 +1,91 @@ +// Why: "paste bytes were written" is not proof the agent's composer received +// them — codex's trust/update screens and claude's login screen silently +// swallow pastes after the readiness heuristics fire (issue #7466). This +// scanner watches the PTY output for visible evidence the pasted content +// rendered, so callers can withhold the submit Enter until delivery is real. + +/** + * Fold terminal output down to comparable text: + * - strip OSC (title/hyperlink) and CSI/charset escape sequences + * - drop every non-alphanumeric character (TUIs repaint with cursor moves + * instead of literal spaces, wrap lines at pane width, and draw box + * borders through the text, so only the letters/digits survive intact) + * - lowercase for case-stable matching + */ +export function foldTerminalTextForEchoMatch(raw: string): string { + /* oxlint-disable no-control-regex -- grammar matches terminal ESC/BEL sequences by definition */ + const withoutEscapes = raw + // OSC ... BEL | OSC ... ST + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + // CSI sequences (parameters + final byte) + .replace(/\x1b\[[0-9;:?<=>]*[ -/]*[@-~]/g, '') + // charset selection / keypad / other two-byte escapes + .replace(/\x1b[()#][0-9A-Za-z]/g, '') + .replace(/\x1b[@-_]/g, '') + /* oxlint-enable no-control-regex */ + let folded = '' + for (const ch of withoutEscapes.toLowerCase()) { + const code = ch.codePointAt(0) ?? 0 + const isDigit = code >= 48 && code <= 57 + const isLower = code >= 97 && code <= 122 + if (isDigit || isLower) { + folded += ch + } + } + return folded +} + +// Why: large pastes never render verbatim — codex collapses to +// "[Pasted Content 4295 chars]" and claude to "[Pasted text #1 +39 lines]" +// (captured from real PTYs). After folding, both start with "pasted". +const FOLDED_PLACEHOLDER_PATTERNS = [/pastedcontent\d+chars/, /pastedtext\d+/] + +const SAMPLE_LENGTH = 24 +const MIN_SAMPLE_LENGTH = 8 +// Why: refold the whole raw ring on every chunk instead of delta-folding — +// escape sequences straddling chunk seams make incremental folds misalign, +// and the scanner only lives for the few seconds after one paste, so the +// bounded rescan is cheap. The ring comfortably exceeds one composer repaint. +const RAW_RING_LIMIT = 32_768 + +export type PromptEchoScanner = { + /** Feed a PTY output chunk; returns true once the pasted content (or the + * agent's paste placeholder) has visibly rendered. Latches once true. */ + observe: (chunk: string) => boolean +} + +export function createPromptEchoScanner(pastedContent: string): PromptEchoScanner { + const folded = foldTerminalTextForEchoMatch(pastedContent) + const head = folded.slice(0, SAMPLE_LENGTH) + const tail = folded.length > SAMPLE_LENGTH ? folded.slice(-SAMPLE_LENGTH) : '' + // Why: content that folds to almost nothing (punctuation-only prompts) + // cannot be matched reliably — treat any post-paste render as echo rather + // than blocking delivery on an impossible match. + const unmatchable = head.length < MIN_SAMPLE_LENGTH + + let rawRing = '' + let echoed = false + + return { + observe(chunk: string): boolean { + if (echoed) { + return true + } + if (unmatchable) { + echoed = chunk.length > 0 + return echoed + } + rawRing = (rawRing + chunk).slice(-RAW_RING_LIMIT) + const foldedRing = foldTerminalTextForEchoMatch(rawRing) + if (foldedRing.includes(head) || (tail !== '' && foldedRing.includes(tail))) { + echoed = true + return true + } + if (FOLDED_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(foldedRing))) { + echoed = true + return true + } + return false + } + } +} diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index cb4169cb1dd..6f8d00459da 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -117,6 +117,9 @@ export type AgentKind = z.infer // - `paste_readiness_timeout` — bracketed-paste readiness wait timed out. // The agent process spawned but its TUI input box didn't reach a ready // state before the watchdog deadline, so the queued draft was dropped. +// - `paste_echo_timeout` — the paste was written after readiness, but the +// content never visibly rendered (trust/update/login screen swallowed +// it), so the submit Enter was withheld and the launch reported failure. // - `unknown` — every other thrown error (env-build failures, // unclassifiable shell-spawn errors). // Provider-side errors (`auth_expired`, `rate_limited`, `network_timeout`, @@ -124,7 +127,12 @@ export type AgentKind = z.infer // to Orca — see telemetry-plan.md §Decision: Defer per-incident error fields. // Adding a new value is additive-safe; do it when the call site lands, not in // anticipation. -export const errorClassSchema = z.enum(['binary_not_found', 'paste_readiness_timeout', 'unknown']) +export const errorClassSchema = z.enum([ + 'binary_not_found', + 'paste_readiness_timeout', + 'paste_echo_timeout', + 'unknown' +]) export type ErrorClass = z.infer export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop'])