fix(agents): verify prompt echo before submitting deferred launch pastes

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 <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-08 15:20:55 -07:00
co-authored by Orca
parent e218bf88f6
commit bede7702b9
10 changed files with 552 additions and 8 deletions
@@ -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',
@@ -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<boolean> {
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) => {
@@ -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<string, string[]>,
runtimePaneTitlesByTabId: {},
tabsByWorktree: {} as Record<string, { id: string; title?: string }[]>,
repos: [] as { id: string; connectionId: string | null }[],
worktreesByRepo: {} as Record<string, { id: string; repoId: string }[]>
},
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<void> {
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<boolean>
onTimeout: ReturnType<typeof vi.fn>
} {
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')
})
})
+43 -4
View File
@@ -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<boolean> {
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<void>((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS))
try {
return await sendRuntimePtyInputVerified(settings, ptyId, '\r')
} catch {
return false
}
}
@@ -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 () => {
@@ -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)
})
}
+78
View File
@@ -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<boolean>
/** 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): PromptEchoWatch {
let cancel: () => void = () => {}
const result = new Promise<boolean>((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 }
}
+93
View File
@@ -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)
})
})
+91
View File
@@ -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
}
}
}
+9 -1
View File
@@ -117,6 +117,9 @@ export type AgentKind = z.infer<typeof agentKindSchema>
// - `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<typeof agentKindSchema>
// 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<typeof errorClassSchema>
export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop'])