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'])