diff --git a/src/cli_agent_orchestrator/providers/codex.py b/src/cli_agent_orchestrator/providers/codex.py index 8d14c84c8..b91d1a2bd 100644 --- a/src/cli_agent_orchestrator/providers/codex.py +++ b/src/cli_agent_orchestrator/providers/codex.py @@ -126,9 +126,86 @@ UPDATE_DIALOG_FOOTER = TRUST_PROMPT_FOOTER STARTUP_PROMPT_BOTTOM_LINES = 15 STARTUP_ACTIVITY_PATTERN = r"^\s*•[^\S\n]+\S" +# Codex's runtime approval prompt as actually rendered by codex-cli 0.147.0, +# verified against a live tmux capture (test/providers/fixtures/ +# codex_approval_modal_raw.txt): +# +# Would you like to run the following command? +# +# Environment: local +# +# $ mkdir -p /private/tmp/codex-work-567 +# +# › 1. Yes, proceed (y) +# 2. Yes, and don't ask again for commands that start with `mkdir -p ...` (p) +# 3. No, and tell Codex what to do differently (esc) +# +# Press enter to confirm or esc to cancel +# +# It is NOT a box-drawn modal and carries no "[a] Accept"/"[d] Decline" keys: it +# is a numbered menu with a `›` selection cursor, structurally identical to the +# trust-v2, login, and update dialogs above -- hence the same question+footer +# corroboration shape. The three question variants are the exec, apply_patch, and +# permission-escalation approvals; all three block the TUI on a keystroke, and all +# three are present in the 0.147.0 binary's string table. +# +# Left un-anchored to the `›` cursor line on purpose: the cursor moves between the +# numbered options as the operator arrows around, so the question and the footer +# are the only two positionally stable rows. +APPROVAL_PROMPT_PATTERN = ( + r"Would you like to (?:run the following command" + r"|make the following edits" + r"|grant these permissions)\?" +) +APPROVAL_PROMPT_FOOTER = r"Press enter to confirm" + +# Codex's boxed command-approval modal: +# ╭─ Command Approval Required ─╮ +# │ [a] Accept [d] Decline │ +# ╰─────────────────────────────╯ +# WARNING: this copy is NOT emitted by codex-cli 0.147.0. `strings` over the +# vendored native binary finds zero occurrences of "Command Approval Required", +# "] Accept", or "] Decline" -- the live prompt is APPROVAL_PROMPT_PATTERN above. +# The two patterns are kept because this copy predates the numbered menu and is +# already load-bearing in STARTUP_BLOCKING_INPUT_PATTERN below, so dropping them +# would silently un-guard whichever older Codex builds still render it. Treat +# _has_approval_modal_in_bottom as legacy/defensive: APPROVAL_PROMPT_PATTERN is +# what fires on current Codex. +# +# Split into header and choice-key halves because the two paths that consume +# them need different strictness. The startup path (_has_startup_idle_composer) +# uses the permissive OR below as a NEGATIVE gate — any one token vetoes +# "ready", and a false veto merely keeps polling, so over-matching is free. +# get_status() uses them as a POSITIVE classifier where over-matching would +# strand a healthy pane in WAITING_USER_ANSWER, so it corroborates the two +# halves separately (see _has_approval_modal_in_bottom). Box-drawing characters +# are deliberately NOT required: the frame chrome has changed across Codex +# releases while this copy has not. +APPROVAL_MODAL_HEADER_PATTERN = r"Command Approval Required" +APPROVAL_MODAL_CHOICE_PATTERN = r"(?:\[[aA]\]\s+Accept\b|\[[dD]\]\s+Decline\b)" +# Box-drawing frame and padding stripped from a modal line before matching, so a +# framed line ("│ [a] Accept [d] Decline │") reduces to its text content. +# Stripped as a character SET from both ends, hence no ordering assumption about +# corner/edge glyphs. Light, heavy, and double variants are all covered because +# only light glyphs have been observed and the frame style is not contractual. +# +# ASCII frame characters (+ - |) are deliberately EXCLUDED. They are markdown +# table syntax, so including them would let a table the model wrote in its own +# reply ("| Command Approval Required |" / "| [a] Accept | [d] Decline |") +# reduce to the exact modal shape. No Codex release has been observed using +# ASCII frames, so that trade buys a hypothetical false negative at the cost of +# a plausible false positive. +# +# Note this set also strips leading whitespace, so an INDENTED plain-text quote +# reduces to the modal shape too. That look-alike is excluded positionally +# instead — see _has_approval_modal_in_bottom. +MODAL_FRAME_CHARS = "─│╭╮╰╯├┤━┃┏┓┗┛┣┫═║╔╗╚╝╠╣ \t" +# The same set minus padding, used to tell "this line began with box chrome" +# from "this line began with a prose indent". +MODAL_FRAME_GLYPHS = frozenset(MODAL_FRAME_CHARS) - frozenset(" \t") STARTUP_BLOCKING_INPUT_PATTERN = ( - r"(?:Command Approval Required|\[[aA]\]\s+Accept\b|" - r"\[[dD]\]\s+Decline\b|Press enter to continue)" + rf"(?:{APPROVAL_MODAL_HEADER_PATTERN}|{APPROVAL_MODAL_CHOICE_PATTERN}|" + rf"{APPROVAL_PROMPT_PATTERN}|{APPROVAL_PROMPT_FOOTER}|{TRUST_PROMPT_FOOTER})" ) STARTUP_IDLE_PLACEHOLDER_PATTERN = ( rf"^\s*{IDLE_PROMPT_PATTERN}[^\S\n]+(?:" @@ -278,6 +355,169 @@ def _has_update_dialog_in_bottom(clean_output: str) -> bool: ) +def _modal_line_content(line: str) -> Optional[str]: + """Reduce one line to its modal text, or None if the line reads as prose. + + Strips frame glyphs and padding so ``"│ [a] Accept [d] Decline │"`` + reduces to ``"[a] Accept [d] Decline"``. Returns None when the leading run + removed was whitespace ONLY while being non-empty — i.e. the line is + indented plain text. + + That indent test is the discriminator against the model quoting a modal + transcript back in its own reply: + + • The terminal output showed: + Command Approval Required + [a] Accept [d] Decline + so it was waiting on approval. + + Those quoted lines reproduce the modal's per-line structure exactly, so + line structure alone cannot separate them. Position can: Codex draws the + modal box flush at the left margin, whereas quoted or continuation prose is + indented under its bullet. So a leading run of frame glyphs is accepted, a + leading run of spaces/tabs is not, and column 0 is accepted either way + (an unframed modal would still start there). + """ + content = line.strip(MODAL_FRAME_CHARS) + if not content: + return None + lead = line[: len(line) - len(line.lstrip(MODAL_FRAME_CHARS))] + if lead and not (MODAL_FRAME_GLYPHS & set(lead)): + return None + return content + + +def _is_frame_padding(line: str) -> bool: + """Return True when ``line`` carries nothing but frame glyphs and padding. + + True of a box's top/bottom rule ("╰────╯"), of an empty interior row + ("│ │"), and of a blank line (space is in ``MODAL_FRAME_CHARS``). + """ + return not line.strip(MODAL_FRAME_CHARS) + + +def _is_chrome_only(line: str) -> bool: + """Return True when ``line`` is frame or TUI chrome rather than content. + + The union of what may legitimately sit BELOW a live modal: the box's own + closing rule and interior padding, blank filler, the empty composer line + ("›" with nothing typed), and the status-bar footer. Anything else -- a + prose bullet, a spinner, a typed draft -- is content, which means the + modal is no longer the bottom of the pane. + + The empty-composer and footer cases are matched explicitly rather than + folded into :func:`_is_frame_padding` because neither ``›`` nor the footer + text reduces to empty under ``MODAL_FRAME_CHARS``. + """ + if _is_frame_padding(line): + return True + if re.fullmatch(rf"\s*{IDLE_PROMPT_PATTERN}\s*", line): + return True + return re.search(TUI_FOOTER_PATTERN, line) is not None + + +def _is_transcript_marker(line: str) -> bool: + """Return True when ``line`` opens a new transcript cell (``›`` user / ``•`` bullet). + + Used as the upward bound on the header search: Codex draws the modal as ONE + cell, so a user line or an assistant bullet is a hard boundary that the box + cannot span. This replaces a fixed line count, which could not express + "same box" and therefore failed open on a modal taller than the window. + """ + return bool( + re.match(USER_PREFIX_PATTERN, line, re.IGNORECASE) + or re.match(ASSISTANT_PREFIX_PATTERN, line, re.IGNORECASE) + ) + + +def _has_approval_modal_in_bottom(clean_output: str) -> bool: + """Return True when Codex's boxed command-approval modal is active at the bottom. + + NOTE: this detects the LEGACY "Command Approval Required" / "[a] Accept" + modal, which codex-cli 0.147.0 does not render — see + APPROVAL_MODAL_HEADER_PATTERN's comment and + :func:`_has_approval_prompt_in_bottom` for the copy that is live today. + + Anchored BOTTOM-UP on the last choice line, because the thing being tested + is an invariant about the bottom of the pane, not about a region of it: a + live modal blocks the TUI, so it must BE the bottom, with only frame rows + and footer chrome after it. Four guards: + + 1. **Anchor.** The LAST line that reduces to a choice key. Taking the last + rather than the first is what lets an already-answered modal sitting in + scrollback above a live one be ignored instead of vetoing it. + 2. **Nothing but chrome below the anchor.** See :func:`_is_chrome_only`. + This subsumes the older spinner test (a spinner is not chrome) and also + rejects a modal transcript the model quoted mid-reply, since the reply + continues below the quote. It replaces "footer must NOT appear below", + which would have false-negatived every real modal: with + ``--no-alt-screen`` the footer renders at the bottom regardless. + 3. **Corroborating header above the anchor,** found by walking up and + stopping at the first :func:`_is_transcript_marker` — the box is one + transcript cell, so the header must be inside it. No fixed window, so an + arbitrarily tall modal still resolves; previously a >15-line modal lost + its header and failed open to COMPLETED. + 4. **Line structure and left-margin position.** Each half must own its line + (header an exact match, choice line a prefix match) and sit at the box's + margin rather than under a prose indent — see :func:`_modal_line_content`. + + Known residual: a framed modal quote that ENDS a reply, with only the empty + composer and footer after it, satisfies all four guards and reads as live. + Distinguishing it needs semantics this detector does not have; it costs a + spurious WAITING_USER_ANSWER (work withheld) rather than a COMPLETED (work + pasted into a blocked pane), which is the safe direction to be wrong in. + """ + lines = clean_output.splitlines() + + choice_idx = None + for index in range(len(lines) - 1, -1, -1): + content = _modal_line_content(lines[index]) + if content is not None and re.match(APPROVAL_MODAL_CHOICE_PATTERN, content, re.IGNORECASE): + choice_idx = index + break + if choice_idx is None: + return False + + if not all(_is_chrome_only(line) for line in lines[choice_idx + 1 :]): + return False + + for index in range(choice_idx - 1, -1, -1): + line = lines[index] + content = _modal_line_content(line) + if content is not None and re.fullmatch( + APPROVAL_MODAL_HEADER_PATTERN, content, re.IGNORECASE + ): + return True + if _is_transcript_marker(line): + return False + return False + + +def _has_approval_prompt_in_bottom(clean_output: str) -> bool: + """Return True when Codex's runtime approval prompt is active at the bottom. + + This is the prompt codex-cli 0.147.0 actually renders (verified against a + live capture; see APPROVAL_PROMPT_PATTERN). Corroborates the question with + its footer inside the bottom region, exactly like the trust-v2, login, and + update dialogs — the prompt is a numbered menu of the same shape, so the + same guard against the copy surviving in scrollback applies. + + BLANK LINES ARE DROPPED before the region is taken. The prompt is ~10 rows + of question, command preview, and options separated by blank filler, and + ``tmux capture-pane`` pads the pane to its full height with empty rows, so a + raw 15-line tail can land entirely inside the padding and see neither half. + Compacting first is what :meth:`CodexProvider.get_status_from_screen` + already does to the pyte viewport, so this makes the buffer path agree with + the screen path rather than inventing a second rule. + """ + rows = [line for line in clean_output.splitlines() if line.strip()] + bottom = "\n".join(rows[-STARTUP_PROMPT_BOTTOM_LINES:]) + return ( + re.search(APPROVAL_PROMPT_PATTERN, bottom) is not None + and re.search(APPROVAL_PROMPT_FOOTER, bottom) is not None + ) + + def _has_startup_idle_composer(clean_output: str) -> bool: """Return True when the bottom of the pane shows Codex's idle composer.""" all_lines = clean_output.splitlines() @@ -893,6 +1133,44 @@ def get_status(self, output: str) -> TerminalStatus: ): return TerminalStatus.WAITING_USER_ANSWER + # Boxed command-approval modal ("Command Approval Required" / "[a] Accept" + # / "[d] Decline"). Reuses the copy that STARTUP_BLOCKING_INPUT_PATTERN + # already vetoes readiness on at startup — the same modal can appear at + # RUNTIME under any approval-prompting codexProfile, and only the startup + # path used to notice it. + # + # Bottom-anchored like trust-v2 and the update dialog, and placed BEFORE + # the idle/COMPLETED classification for the same reason: the TUI composer + # and status bar keep rendering while the modal is up, so the idle-prompt + # check below would otherwise report COMPLETED (or PROCESSING when the + # composer has scrolled off) for a pane that is hard-blocked on a + # keystroke. A COMPLETED there is the dangerous case — it tells the + # conductor the agent is free and invites more work into a dead pane. + # + # NOT gated on `not assistant_after_last_user` (unlike WAITING_PROMPT_PATTERN + # below): the modal is raised mid-turn, after the model has already emitted + # bullets, so that gate would suppress every real occurrence. Prose that + # merely quotes the copy is excluded structurally instead — see + # _has_approval_modal_in_bottom. + if _has_approval_modal_in_bottom(clean_output): + return TerminalStatus.WAITING_USER_ANSWER + + # Runtime approval prompt as codex-cli 0.147.0 actually renders it -- a + # numbered menu, not the boxed modal above. This is the check that fires on + # a current-Codex approval; without it a live prompt classified as IDLE + # (verified against the live capture in + # test/providers/fixtures/codex_approval_modal_raw.txt), because the + # prompt's own "› 1. Yes, proceed (y)" cursor line is both the last + # USER_PREFIX_PATTERN match and an idle-prompt match, so the classification + # below saw a user message with no reply after it. IDLE is as dangerous as + # COMPLETED here: both tell the conductor the pane is free. + # + # Placed after the legacy modal check and before the idle classification, + # for the same reason: the composer and status bar keep rendering while the + # prompt is up, so the idle-prompt check cannot see the block. + if _has_approval_prompt_in_bottom(clean_output): + return TerminalStatus.WAITING_USER_ANSWER + # Check bottom of captured output for idle prompt. # With --no-alt-screen, scrollback contains history so we can't anchor # to end-of-string. Instead, check only the last few lines. diff --git a/test/providers/fixtures/codex_approval_edits_raw.txt b/test/providers/fixtures/codex_approval_edits_raw.txt new file mode 100644 index 000000000..6ea359f27 --- /dev/null +++ b/test/providers/fixtures/codex_approval_edits_raw.txt @@ -0,0 +1,40 @@ +╭──────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.147.0) │ +│ │ +│ model: openai.gpt-5.6-sol low /model to change │ +│ directory: /private/tmp/codex-work-567 │ +╰──────────────────────────────────────────────────────╯ + + Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true + + +› Run this shell command now, do not explain first: mkdir -p /private/tmp/codex-work-567/subdir + + +✗ You canceled the request to run mkdir -p /private/tmp/codex-work-567/subdir + +• Ran mkdir -p /private/tmp/codex-work-567/subdir + └ (no output) + +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the +issue. + + +› Now edit note.txt and change hello to goodbye. Do it now, no explanation. + + +• Edited note.txt (+1 -1) + 1 -hello + 1 +goodbye + + + Would you like to make the following edits? + + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for these files (a) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel + + diff --git a/test/providers/fixtures/codex_approval_modal.txt b/test/providers/fixtures/codex_approval_modal.txt new file mode 100644 index 000000000..aeb3b2a99 --- /dev/null +++ b/test/providers/fixtures/codex_approval_modal.txt @@ -0,0 +1,11 @@ +› run the deploy script +• I'll run the deploy script now. + +• Called shell.exec({"command":"./scripts/deploy.sh"}) + +╭─ Command Approval Required ──────────────────╮ +│ │ +│ ./scripts/deploy.sh │ +│ │ +│ [a] Accept [d] Decline │ +╰──────────────────────────────────────────────╯ diff --git a/test/providers/fixtures/codex_approval_modal_heavy_box.txt b/test/providers/fixtures/codex_approval_modal_heavy_box.txt new file mode 100644 index 000000000..6eb7a482a --- /dev/null +++ b/test/providers/fixtures/codex_approval_modal_heavy_box.txt @@ -0,0 +1,9 @@ +› run the deploy script +• I'll run the deploy script now. + +┏━ Command Approval Required ━━━━━━━━━━━━━━━━━━┓ +┃ ┃ +┃ ./scripts/deploy.sh ┃ +┃ ┃ +┃ [a] Accept [d] Decline ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/test/providers/fixtures/codex_approval_modal_quoted_in_reply.txt b/test/providers/fixtures/codex_approval_modal_quoted_in_reply.txt new file mode 100644 index 000000000..4ef68f6cc --- /dev/null +++ b/test/providers/fixtures/codex_approval_modal_quoted_in_reply.txt @@ -0,0 +1,11 @@ +› why did the earlier run stall? +• The terminal output showed: + + Command Approval Required + [a] Accept [d] Decline + + so the pane was blocked waiting on approval. I have since set + approval_policy = "never" in the profile, so it will not recur. + +› + openai.gpt-5.6-sol high · ~/wt diff --git a/test/providers/fixtures/codex_approval_modal_raw.txt b/test/providers/fixtures/codex_approval_modal_raw.txt new file mode 100644 index 000000000..a97a1af57 --- /dev/null +++ b/test/providers/fixtures/codex_approval_modal_raw.txt @@ -0,0 +1,40 @@ +╭──────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.147.0) │ +│ │ +│ model: openai.gpt-5.6-sol low /model to change │ +│ directory: /private/tmp/codex-work-567 │ +╰──────────────────────────────────────────────────────╯ + + Tip: New Build faster with the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true + + +› Run this shell command now, do not explain first: mkdir -p /private/tmp/codex-work-567/subdir + + +• Running mkdir -p /private/tmp/codex-work-567/subdir + + + Would you like to run the following command? + + Environment: local + + $ mkdir -p /private/tmp/codex-work-567/subdir + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `mkdir -p /private/tmp/codex-work-567/subdir` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel + + + + + + + + + + + + + diff --git a/test/providers/fixtures/codex_approval_modal_scrollback.txt b/test/providers/fixtures/codex_approval_modal_scrollback.txt new file mode 100644 index 000000000..2158c6f4e --- /dev/null +++ b/test/providers/fixtures/codex_approval_modal_scrollback.txt @@ -0,0 +1,22 @@ +› run the deploy script +• I'll run the deploy script now. + +╭─ Command Approval Required ──────────────────╮ +│ │ +│ ./scripts/deploy.sh │ +│ │ +│ [a] Accept [d] Decline │ +╰──────────────────────────────────────────────╯ + +• Accepted — running ./scripts/deploy.sh +• Uploading build artifacts. +• Waiting for the health check. +• Health check passed. +• Draining the old revision. +• Old revision drained. +• Invalidating the CDN cache. +• Cache invalidated. +• Deploy finished successfully in 84s. + +› + ? for shortcuts 88% context left diff --git a/test/providers/test_codex_provider_unit.py b/test/providers/test_codex_provider_unit.py index 2cbdb5e0f..9d1770801 100644 --- a/test/providers/test_codex_provider_unit.py +++ b/test/providers/test_codex_provider_unit.py @@ -14,6 +14,8 @@ CodexProvider, ProviderError, _find_response_marker, + _has_approval_modal_in_bottom, + _has_approval_prompt_in_bottom, _has_startup_idle_composer, _toml_override, _toml_scalar, @@ -2697,6 +2699,570 @@ def test_update_check_suppression_is_last_override(self, mock_load): assert command.endswith("-c check_for_update_on_startup=false") +class TestCodexProviderApprovalModal: + """Tests for Codex's boxed command-approval modal appearing at RUNTIME. + + The modal's copy was previously only consulted on the startup path + (STARTUP_BLOCKING_INPUT_PATTERN in _has_startup_idle_composer), so a pane + blocked on it mid-session was classified COMPLETED/PROCESSING and the + conductor would keep sending work into a pane hard-blocked on a keystroke. + """ + + def test_get_status_approval_modal_waiting(self): + """Active approval modal classifies as WAITING_USER_ANSWER.""" + output = load_fixture("codex_approval_modal.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_approval_modal_below_tui_footer_is_not_completed(self): + """Composer chrome above the modal must not win over the modal. + + This is the dangerous shape: the TUI keeps rendering the idle composer + and status bar while the modal is up, so the idle-prompt check reported + COMPLETED — telling the conductor the agent was free. + """ + output = ( + "› run the deploy script\n" + "• I'll run the deploy script now.\n" + "› \n" + " ? for shortcuts 92% context left\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_approval_modal_unframed(self): + """Modal without box-drawing chrome still classifies as WAITING. + + The frame glyphs have changed across Codex releases while the copy has + not, so detection must not depend on them. + """ + output = "› run the deploy script\nCommand Approval Required\n[a] Accept [d] Decline\n" + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_approval_modal_in_scrollback_is_completed(self): + """An already-answered modal scrolled out of the bottom region must not latch.""" + output = load_fixture("codex_approval_modal_scrollback.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.COMPLETED + + def test_get_status_approval_modal_quoted_in_assistant_reply_is_completed(self): + """The model describing the modal in prose must not be read as the modal. + + Cannot be excluded by the assistant_after_last_user gate — a real modal + also appears after assistant bullets — so it is excluded structurally: + prose embeds the copy mid-sentence instead of owning its own line. + """ + output = ( + "› why did the last run stall?\n" + "• The pane was blocked on Codex's Command Approval Required modal, " + "which offers [a] Accept and [d] Decline and cannot be answered by CAO.\n" + '• Switch the profile to approval_policy = "never" to avoid it.\n' + "› \n" + " ? for shortcuts 91% context left\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.COMPLETED + + def test_get_status_choice_keys_without_header_is_completed(self): + """Choice keys alone are not a modal — both halves must corroborate.""" + output = ( + "› list the approval keys\n" + "• [a] Accept and [d] Decline are the approval keys.\n" + "› \n" + " ? for shortcuts 91% context left\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.COMPLETED + + def test_get_status_header_without_choice_keys_is_not_waiting(self): + """Header alone is not a modal — the choice line must be present too.""" + output = "› run the deploy script\n╭─ Command Approval Required ─╮\n" + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status != TerminalStatus.WAITING_USER_ANSWER + + def test_has_approval_modal_requires_header_above_choices(self): + """Choice keys ABOVE the header are a partial/scrolled frame, not a live modal.""" + assert not _has_approval_modal_in_bottom( + "│ [a] Accept [d] Decline │\n╭─ Command Approval Required ─╮\n" + ) + assert _has_approval_modal_in_bottom( + "╭─ Command Approval Required ─╮\n│ [a] Accept [d] Decline │\n" + ) + + def test_get_status_modal_quoted_as_indented_transcript_is_completed(self): + """The model quoting a modal TRANSCRIPT back must not be read as the modal. + + Harder than prose: the quoted block reproduces the modal's per-line + structure exactly (header alone on its line, choice keys starting their + line), so it satisfies the corroboration and line-structure guards. Only + the left-margin guard separates it — the quote is indented under its + bullet, the real box is drawn at the margin. + """ + output = load_fixture("codex_approval_modal_quoted_in_reply.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.COMPLETED + + def test_get_status_modal_quoted_as_markdown_table_is_completed(self): + """A modal transcribed into a markdown table must not be read as the modal. + + Motivates excluding ASCII ``+-|`` from MODAL_FRAME_CHARS: were they + stripped as frame chrome, these rows would reduce to the modal shape + while sitting at the left margin, defeating every guard. + """ + output = ( + "› document the approval modal\n" + "• I documented it as:\n" + "| Command Approval Required |\n" + "| [a] Accept | [d] Decline |\n" + "› \n" + " ? for shortcuts 90% context left\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.COMPLETED + + def test_get_status_approval_modal_heavy_box(self): + """A modal framed in heavy box-drawing glyphs still classifies as WAITING. + + Defensive: only light glyphs have been observed in the wild, but the + frame style is not contractual and missing a real modal is the costly + direction. + """ + output = load_fixture("codex_approval_modal_heavy_box.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_approval_modal_double_box(self): + """Double-line frame glyphs are stripped as chrome too.""" + output = ( + "› run the deploy script\n" + "╔═ Command Approval Required ═╗\n" + "║ [a] Accept [d] Decline ║\n" + "╚════════════════════════════╝\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_has_approval_modal_accepts_framed_box_with_whitespace_gutter(self): + """A framed box indented as a whole is still a modal. + + The left-margin guard rejects a leading run of whitespace ONLY; a run + containing frame glyphs is chrome regardless of surrounding padding, so + indenting the box does not break detection. + """ + assert _has_approval_modal_in_bottom( + " ╭─ Command Approval Required ─╮\n │ [a] Accept [d] Decline │\n" + ) + + def test_has_approval_modal_accepts_unframed_modal_at_left_margin(self): + """An unframed modal at column 0 is accepted — no indent, so no prose signal.""" + assert _has_approval_modal_in_bottom("Command Approval Required\n[a] Accept [d] Decline\n") + + def test_has_approval_modal_requires_both_halves_at_left_margin(self): + """One half framed and the other indented is a quote, not a box.""" + assert not _has_approval_modal_in_bottom( + "╭─ Command Approval Required ─╮\n [a] Accept [d] Decline\n" + ) + assert not _has_approval_modal_in_bottom( + " Command Approval Required\n│ [a] Accept [d] Decline │\n" + ) + + def test_get_status_answered_modal_with_work_resumed_is_not_waiting(self): + """An answered modal still in-window, with work running below it, is not WAITING. + + The box has not scrolled out yet, so guards 1-4 all pass; only the + spinner below the choice line reveals that the modal was answered and + execution resumed. Reporting WAITING here withholds work from a pane + that is actively running. + """ + output = ( + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + "• Accepted. Running deploy...\n" + "• Working (3s • esc to interrupt)\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status != TerminalStatus.WAITING_USER_ANSWER + assert status == TerminalStatus.PROCESSING + + def test_get_status_live_modal_without_spinner_is_still_waiting(self): + """Control for the spinner guard: no spinner below the box means WAITING.""" + output = ( + "› run the deploy script\n" + "• I'll run the deploy script now.\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_live_modal_with_stale_spinner_above_is_waiting(self): + """A spinner in scrollback ABOVE the box must not suppress a live modal. + + Why the spinner guard is scoped to lines strictly below the choice line + rather than the whole bottom region: with --no-alt-screen a spinner from + earlier in the same turn can survive above the box, and a region-wide + test would then miss a genuinely blocked pane. + """ + output = ( + "› run the deploy script\n" + "• Working (5s • esc to interrupt)\n" + "• I need approval to run this.\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + status = provider.get_status(output) + + assert status == TerminalStatus.WAITING_USER_ANSWER + + def test_startup_blocking_input_pattern_still_vetoes_readiness(self): + """Splitting the startup pattern must not weaken the startup-path veto.""" + assert not _has_startup_idle_composer( + "› Write tests for @filename\n" + " gpt-5.6-sol medium · Context 100% left\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + ) + + def test_modal_taller_than_bottom_region_is_still_waiting(self): + """A modal taller than STARTUP_PROMPT_BOTTOM_LINES must not fail open. + + The first implementation searched only the bottom 15 lines for BOTH + halves, so a box with a long command preview pushed its header out of + the window, dropped the corroboration guard, and returned COMPLETED — + the exact "pane is free" misreport this class exists to prevent. + Anchoring bottom-up on the choice line and walking up to the enclosing + transcript cell removes the height ceiling. + """ + preview = "".join(f"│ arg-{n:02d}={'x' * 30} │\n" for n in range(20)) + output = ( + "› run the deploy script\n" + "• I'll run the deploy script now.\n" + "╭─ Command Approval Required ─╮\n" + preview + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + ) + + assert _has_approval_modal_in_bottom(output) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + + def test_answered_modal_above_live_modal_does_not_veto_the_live_one(self): + """An answered modal above a live one must not suppress the live one. + + Top-down anchoring found the FIRST header and paired it with the FIRST + choice line below it, then judged liveness from THAT box. The spinner + left in scrollback by the first command's execution sits below the first + choice line, so the answered box vetoed the whole detector and the live + box below was never considered — get_status fell through to PROCESSING. + Anchoring on the LAST choice line makes the live modal the subject. + + The surviving spinner is the same --no-alt-screen artefact that + test_get_status_live_modal_with_stale_spinner_above_is_waiting relies on. + """ + output = ( + "› run both deploy scripts\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + "• Accepted — running ./scripts/deploy-a.sh.\n" + "• Working (12s • esc to interrupt)\n" + "• deploy-a.sh finished. deploy-b.sh needs approval.\n" + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + ) + + assert _has_approval_modal_in_bottom(output) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + + def test_framed_quote_mid_reply_is_not_waiting(self): + """A framed modal quote the reply CONTINUES past must not latch WAITING. + + Harder than the indented plain-text quote: the model reproduces the box + glyphs too, so the left-margin guard passes (the leading run contains + frame chrome, not just spaces) and the old detector latched + WAITING_USER_ANSWER for as long as the reply stayed on screen — work + withheld from an idle pane indefinitely. + + The discriminator is positional: a live modal IS the bottom of the pane, + so only frame rows and footer chrome may follow it. Here the reply's own + closing sentence follows, which no live modal can have below it. + """ + output = ( + "› why did the earlier run stall?\n" + "• The terminal showed:\n" + " ╭─ Command Approval Required ─╮\n" + " │ [a] Accept [d] Decline │\n" + " ╰─────────────────────────────╯\n" + " so the pane was blocked on approval.\n" + "› \n" + " ? for shortcuts 88% context left\n" + ) + + assert not _has_approval_modal_in_bottom(output) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.COMPLETED + + def test_typed_draft_below_modal_is_not_waiting(self): + """Text typed into the composer below the box means the box is not the bottom. + + Control for _is_chrome_only's composer case: the EMPTY composer is + chrome, a composer holding a draft is content. + """ + assert not _has_approval_modal_in_bottom( + "╭─ Command Approval Required ─╮\n" + "│ [a] Accept [d] Decline │\n" + "╰─────────────────────────────╯\n" + "› and now do the other thing\n" + ) + + def test_framed_quote_ending_a_reply_is_a_known_false_positive(self): + """Documents the one accepted misread: a framed quote that ENDS the reply. + + With only the empty composer and status bar after it, the quote is + positionally indistinguishable from a live modal — separating them needs + semantics this detector does not have. Asserted rather than left + undocumented so the behaviour is a recorded trade, not a surprise. + + Costs a spurious WAITING_USER_ANSWER (work withheld from an idle pane) + rather than a COMPLETED (work pasted into a hard-blocked pane), which is + the safe direction for this detector to be wrong in. + """ + output = ( + "› why did the earlier run stall?\n" + "• The terminal showed:\n" + " ╭─ Command Approval Required ─╮\n" + " │ [a] Accept [d] Decline │\n" + " ╰─────────────────────────────╯\n" + "› \n" + " ? for shortcuts 88% context left\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + + +class TestCodexProviderApprovalPromptLive: + """Tests for the approval prompt codex-cli 0.147.0 ACTUALLY renders. + + The boxed "Command Approval Required" / "[a] Accept" modal that + TestCodexProviderApprovalModal covers is not emitted by 0.147.0 at all -- + ``strings`` over the vendored native binary finds zero occurrences of that + copy. The live prompt is a numbered menu (see the fixture below), so without + APPROVAL_PROMPT_PATTERN a pane hard-blocked on a real approval classified as + IDLE: the prompt's own "› 1. Yes, proceed (y)" cursor line is simultaneously + the last USER_PREFIX_PATTERN match and an idle-prompt match, so get_status + saw a user message with no reply after it. + """ + + def test_get_status_live_capture_is_waiting(self): + """Regression against a real captured approval prompt. + + Fixture is an unedited ``tmux capture-pane -p`` of codex-cli 0.147.0 + parked on an exec approval, produced by launching + ``codex -a untrusted -s read-only --no-alt-screen`` and asking it to run + ``mkdir -p``. Before APPROVAL_PROMPT_PATTERN this returned IDLE. + """ + output = load_fixture("codex_approval_modal_raw.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + + def test_get_status_from_screen_live_capture_is_waiting(self): + """The pyte-composited screen path must agree with the buffer path. + + supports_screen_detection is True for this provider, so the screen path + is what StatusMonitor actually calls in production. + """ + output = load_fixture("codex_approval_modal_raw.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert ( + provider.get_status_from_screen(output.splitlines()) + == TerminalStatus.WAITING_USER_ANSWER + ) + + def test_get_status_live_capture_edits_approval_is_waiting(self): + """Regression against a real captured apply_patch approval. + + Second unedited capture from the same live session, parked on + "Would you like to make the following edits?" after being asked to edit a + file under ``-s read-only``. Corroborates that the variants share one + prompt shape and footer rather than being three unrelated screens. + Returns IDLE without APPROVAL_PROMPT_PATTERN, same as the exec capture. + """ + output = load_fixture("codex_approval_edits_raw.txt") + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + assert ( + provider.get_status_from_screen(output.splitlines()) + == TerminalStatus.WAITING_USER_ANSWER + ) + + def test_capture_pane_trailing_padding_does_not_hide_the_prompt(self): + """Blank padding rows must not push the question out of the bottom region. + + ``tmux capture-pane`` pads to the full pane height, and the prompt is + ~10 rows tall, so a raw 15-line tail can land entirely inside the + padding. This is the concrete reason _has_approval_prompt_in_bottom + compacts blank lines before taking the region. + """ + prompt = ( + " Would you like to run the following command?\n" + "\n" + " Environment: local\n" + "\n" + " $ mkdir -p /tmp/subdir\n" + "\n" + "› 1. Yes, proceed (y)\n" + " 2. No, and tell Codex what to do differently (esc)\n" + "\n" + " Press enter to confirm or esc to cancel\n" + ) + + assert _has_approval_prompt_in_bottom(prompt + "\n" * 20) + + @pytest.mark.parametrize( + "question", + [ + "Would you like to run the following command?", + "Would you like to make the following edits?", + "Would you like to grant these permissions?", + ], + ) + def test_all_three_approval_variants_are_waiting(self, question): + """exec, apply_patch, and permission-escalation approvals all block the TUI. + + All three strings are present in the 0.147.0 binary and all three park + the pane on the same numbered menu. + """ + output = ( + "› do the thing\n" + "• Working on it.\n" + f" {question}\n" + "\n" + "› 1. Yes, proceed (y)\n" + " 2. No, and tell Codex what to do differently (esc)\n" + "\n" + " Press enter to confirm or esc to cancel\n" + ) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.WAITING_USER_ANSWER + + def test_question_without_footer_is_not_waiting(self): + """Corroboration guard: the question alone does not classify as WAITING.""" + assert not _has_approval_prompt_in_bottom( + "› why did it stall?\n" + "• Codex asked 'Would you like to run the following command?' and waited.\n" + "› \n" + " ? for shortcuts 88% context left\n" + ) + + def test_footer_without_question_is_not_waiting(self): + """Corroboration guard: the footer alone does not classify as WAITING. + + "Press enter to confirm" also appears under non-approval prompts, so on + its own it is not evidence of an approval. + """ + assert not _has_approval_prompt_in_bottom( + " Name this session\n Press enter to confirm or esc to cancel\n" + ) + + def test_answered_prompt_scrolled_out_is_not_waiting(self): + """Once the prompt scrolls out of the region it must stop latching.""" + output = ( + " Would you like to run the following command?\n" + " $ mkdir -p /tmp/subdir\n" + "› 1. Yes, proceed (y)\n" + " Press enter to confirm or esc to cancel\n" + + "".join(f"• step {n} done.\n" for n in range(16)) + + "› \n" + " ? for shortcuts 88% context left\n" + ) + + assert not _has_approval_prompt_in_bottom(output) + + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status(output) == TerminalStatus.COMPLETED + + def test_startup_path_vetoes_readiness_on_the_live_prompt(self): + """The startup readiness veto must know the copy Codex actually emits. + + STARTUP_BLOCKING_INPUT_PATTERN only carried the legacy modal's copy, so + a pane parked on a real approval during initialize() could be read as an + idle composer and declared ready. + """ + assert not _has_startup_idle_composer( + "› Write tests for @filename\n" + " gpt-5.6-sol medium · Context 100% left\n" + " Would you like to run the following command?\n" + "› 1. Yes, proceed (y)\n" + " Press enter to confirm or esc to cancel\n" + ) + + class TestCodexProviderUpdateDialogLive: """Live binary tests for update dialog config key. Requires CAO_RUN_LIVE_PROVIDER_TESTS=1."""