mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(runtime): bound terminal-wait blocked-prompt rules to the live screen bottom (#18817)
The Codex prompt rules in terminal-wait-detection scanned the whole retained tail (up to 256 KiB) with lastIndexOf, so any quoted prompt phrase in scrollback registered as a live prompt. A Codex agent working on Orca prints rg hits from this very file; one such line ~300 lines above an idle input box made `orca terminal send` refuse with agent_prompt_blocked and `terminal wait --for tui-idle` report codex-interactive-prompt. `clear` did not help because the detector reads the retained tail, not the visible screen. A prompt that owns the terminal is at the screen bottom, so every blocked rule now runs over the last 12 non-blank lines (real Codex dialogs are 4-8 lines), the way the cursor approval rule already was. The returned index is offset back into full-tail coordinates so ready-header comparisons keep working. The sentinel fast path is unchanged. Line-window primitives move to terminal-wait-tail-window.ts to keep the detector under the max-lines cap.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user