diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts new file mode 100644 index 00000000000..eda02e60bbb --- /dev/null +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptPreview +} from './terminal-wait-detection' +import { buildTerminalWaitText } from './terminal-wait-tail-state' + +// Why these shapes: Codex agents working on Orca print `rg` hits from this very detector and its +// specs, so quoted prompt wording lands in scrollback while the terminal sits at its input box. +const QUOTED_DETECTOR_SOURCE_LINE = + "└ if (hooksindex !== -1 && normalized.includes('press enter to confirm', hooksindex)) {" +const QUOTED_PERMISSION_FIXTURE_LINE = + " └ 236: 'Permission required\\nThis command requires permission\\nAllow once\\nAllow always\\nReject\\n'," + +function codexIdleScreen(): string[] { + return [ + '• Done. The detector bounding is in place and the suite passes.', + '', + '› Ask Codex to do anything', + '', + ' gpt-6-astra medium · ~/orca/workspaces/orca/fix-wait-detector-scrollback' + ] +} + +function codexScrollback(quotedLines: string[], trailingLineCount: number): string[] { + const lines: string[] = [ + '• Explored', + ' └ Search press enter to confirm in src/main/runtime', + ' Read terminal-wait-detection.ts', + '', + '• Ran rg -n "press enter to confirm" src/main/runtime/terminal-wait-detection.ts src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts', + ' └ src/main/runtime/terminal-wait-detection.ts', + ' src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts', + ' src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts', + ...quotedLines + ] + for (let index = 0; index < trailingLineCount; index += 1) { + lines.push(` ${index}: unrelated codex narration about hook wiring and sandbox policy`) + } + return lines +} + +function waitTextFor(lines: string[]): string { + return buildTerminalWaitText(lines, '', '') +} + +describe('detectTerminalWaitBlockedReason scrollback bounding', () => { + it('ignores detector source quoted by rg output far above an idle Codex input box', () => { + const waitText = waitTextFor([ + ...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE], 300), + ...codexIdleScreen() + ]) + + expect(waitText).toContain('press enter to confirm') + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + }) + + it('ignores a quoted permission fixture in scrollback above an idle Codex input box', () => { + const waitText = waitTextFor([ + ...codexScrollback([QUOTED_PERMISSION_FIXTURE_LINE], 300), + ...codexIdleScreen() + ]) + + expect(waitText.toLowerCase()).toContain('allow once') + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + }) + + it('ignores quoted prompt wording just above the live-dialog window', () => { + // Why 10: with the 3-line idle screen the quoted lines sit 13-14 non-blank lines from the bottom. + const waitText = waitTextFor([ + ...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE, QUOTED_PERMISSION_FIXTURE_LINE], 10), + ...codexIdleScreen() + ]) + + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + }) + + it('does not let quoted scrollback wording veto a Codex ready header', () => { + const waitText = waitTextFor([ + ...codexScrollback([QUOTED_PERMISSION_FIXTURE_LINE], 40), + ' >_ OpenAI Codex (v0.153.3)', + ' model: gpt-6-astra medium /model to change', + ' directory: ~/orca/workspaces/orca/fix-wait-detector-scrollback' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + }) +}) + +// Real dialog text: terminal-creation-and-readiness-part-07.spec.ts and agent-status-and-waits.spec.ts. +const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = [ + { + name: 'hooks review', + lines: [ + 'Hooks need review', + '2 hooks are new or changed.', + '1. Review hooks', + '2. Trust all and continue', + 'Press enter to confirm or esc to go back' + ], + reason: 'codex-hooks-review-prompt' + }, + { + name: 'trust workspace', + lines: ['Do you trust this workspace directory?', '1. Yes', '2. No'], + reason: 'codex-trust-workspace' + }, + { + name: 'update', + lines: [ + 'Update available! 0.131.0 -> 0.132.0', + '1. Update now', + '2. Skip', + 'Press enter to continue' + ], + reason: 'codex-update-prompt' + }, + { + name: 'cwd selection', + lines: [ + 'Choose working directory to resume this session', + ' Session = latest cwd recorded in the resumed session', + ' Current = your current working directory', + ' Press enter to continue' + ], + reason: 'codex-cwd-prompt' + }, + { + name: 'model migration', + lines: [ + 'Codex just got an upgrade. Introducing gpt-5.1-codex-max.', + 'We recommend switching from gpt-5-codex to gpt-5.1-codex-max.', + 'Press enter to continue' + ], + reason: 'codex-model-migration-prompt' + }, + { + name: 'grant permissions', + lines: [ + 'Would you like to grant these permissions?', + '1. Yes, grant these permissions for this turn', + '2. No, continue without permissions', + 'Press enter to confirm or esc to cancel' + ], + reason: 'codex-interactive-prompt' + }, + { + name: 'permission required', + lines: [ + 'Permission required', + 'This command requires permission', + 'Allow once', + 'Allow always', + 'Reject' + ], + reason: 'codex-interactive-prompt' + } +] + +describe('detectTerminalWaitBlockedReason live prompts', () => { + for (const prompt of LIVE_CODEX_PROMPTS) { + it(`still blocks on a live ${prompt.name} prompt after long scrollback`, () => { + const waitText = waitTextFor([ + ...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE, QUOTED_PERMISSION_FIXTURE_LINE], 300), + ...prompt.lines + ]) + + expect(detectTerminalWaitBlockedReason(waitText)).toBe(prompt.reason) + }) + + it(`blocks on a live ${prompt.name} prompt rendered with blank spacer rows`, () => { + // Why: the visible-screen probe joins raw rows, so blank rows between dialog lines must not eat the window. + const spaced = prompt.lines.flatMap((line) => [line, '', '']) + const screen = [ + ' >_ OpenAI Codex (v0.153.3)', + '', + ...spaced, + '', + ' gpt-6-astra medium · ~/orca/workspaces/orca/fix-wait-detector-scrollback', + '' + ].join('\n') + + expect(detectTerminalWaitBlockedReason(screen)).toBe(prompt.reason) + }) + } + + it('reports the newest prompt when a live dialog follows a stale one at the bottom', () => { + const waitText = waitTextFor([ + 'Update available! 0.131.0 -> 0.132.0', + 'Press enter to continue', + ' >_ OpenAI Codex (v0.132.0)', + ' model: gpt-5.5 high /model to change', + ' directory: ~/orca/workspaces/orca/cli-debug', + 'Hooks need review', + 'Press enter to confirm' + ]) + + expect(detectTerminalWaitBlockedReason(waitText)).toBe('codex-hooks-review-prompt') + }) +}) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 85f05f8e699..ed957d1fe2d 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -4,6 +4,11 @@ import { type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import { + isTerminalWaitWhitespace, + startOfLastLines, + startOfLastNonBlankLines +} from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -153,11 +158,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null } -function isTerminalWaitWhitespace(value: string, index: number): boolean { - const code = value.charCodeAt(index) - return code === 32 || (code >= 9 && code <= 13) -} - export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i @@ -206,25 +206,28 @@ function isCursorApprovalChoiceLine(line: string): boolean { ) } -function startOfLastLines(value: string, count: number): number { - let cursor = value.length - for (let seen = 0; seen < count; seen += 1) { - const previous = value.lastIndexOf('\n', cursor - 1) - if (previous === -1) { - return 0 - } - cursor = previous - } - return cursor + 1 -} +// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the +// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review, +// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list. +const LIVE_PROMPT_TAIL_LINES = 12 function findTerminalWaitBlockedSignal( - normalized: string + fullTail: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. + const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) + const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) + // Why: one combined negative scan avoids a dozen searches when no prompt can match. if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { return null } + const signal = findBlockedSignalInLiveWindow(normalized) + // Why: callers compare this index against ready-header indexes found over the full tail. + return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart } +} + +function findBlockedSignalInLiveWindow( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { diff --git a/src/main/runtime/terminal-wait-tail-window.ts b/src/main/runtime/terminal-wait-tail-window.ts new file mode 100644 index 00000000000..788000328f1 --- /dev/null +++ b/src/main/runtime/terminal-wait-tail-window.ts @@ -0,0 +1,48 @@ +// Line-window primitives over a newline-joined terminal tail, shared by the wait-blocked prompt rules. + +export function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + +/** Offset where the last `count` lines begin (0 when the tail is shorter). */ +export function startOfLastLines(value: string, count: number): number { + let cursor = value.length + for (let seen = 0; seen < count; seen += 1) { + const previous = value.lastIndexOf('\n', cursor - 1) + if (previous === -1) { + return 0 + } + cursor = previous + } + return cursor + 1 +} + +/** Like `startOfLastLines`, but blank rows don't count toward the window. */ +// Why: the visible-screen probe joins raw rows, so blank spacer rows must not eat a dialog's window. +export function startOfLastNonBlankLines(value: string, count: number): number { + let seen = 0 + let lineEnd = value.length + for (;;) { + const lineStart = value.lastIndexOf('\n', lineEnd - 1) + 1 + if (hasNonWhitespaceBetween(value, lineStart, lineEnd)) { + seen += 1 + if (seen >= count) { + return lineStart + } + } + if (lineStart === 0) { + return 0 + } + lineEnd = lineStart - 1 + } +} + +function hasNonWhitespaceBetween(value: string, start: number, end: number): boolean { + for (let index = start; index < end; index += 1) { + if (!isTerminalWaitWhitespace(value, index)) { + return true + } + } + return false +}