diff --git a/src/cli_agent_orchestrator/providers/codex.py b/src/cli_agent_orchestrator/providers/codex.py index 772fa47e3..819afa5d2 100644 --- a/src/cli_agent_orchestrator/providers/codex.py +++ b/src/cli_agent_orchestrator/providers/codex.py @@ -72,12 +72,13 @@ # which is shared across v0.111 and v0.136 status bars. TUI_FOOTER_PATTERN = r"(?:\?\s+for shortcuts|context left|\d+%\s+left|·\s+[~/])" # Codex TUI progress spinner: "• Working (0s • esc to interrupt)", -# "• Thinking (2s ...)", "• Starting script creation (10s • esc to interrupt)". -# The prefix text varies but the "(Ns • esc to interrupt)" format is consistent. +# "• Working (1m 00s ...)", "• Working (1h 00m 00s ...)", or dynamic +# prefixes such as "• Starting script creation (10s • esc to interrupt)". +# Codex expands the elapsed value at the minute and hour boundaries. # Appears inline with --no-alt-screen when the agent is actively processing. # Must be checked before COMPLETED to avoid false positives (the • matches # ASSISTANT_PREFIX_PATTERN and the TUI footer › matches idle prompt). -TUI_PROGRESS_PATTERN = r"•.*\(\d+s\s*•\s*esc to interrupt\)" +TUI_PROGRESS_PATTERN = r"•[^\n]*\((?:(?:\d+h\s+)?\d+m\s+)?\d+s\s*•\s*esc to interrupt\)" # Workspace trust/approval prompt shown when Codex opens a new directory. # Two known variants: @@ -333,6 +334,13 @@ class ProviderError(Exception): class CodexProvider(BaseProvider): """Provider for Codex CLI tool integration.""" + # Codex redraws its inline TUI in place. The append-only pipe-pane stream + # therefore retains transient progress frames (notably MCP startup) after + # they have been erased from the visible terminal. Route status detection + # through StatusMonitor's pyte-composited viewport so get_status() sees only + # the live frame rather than stale redraw history. + supports_screen_detection = True + def __init__( self, terminal_id: str, @@ -893,6 +901,20 @@ def get_status(self, output: str) -> TerminalStatus: # assume the CLI is still producing output. return TerminalStatus.PROCESSING + def get_status_from_screen(self, screen_lines: list[str]) -> TerminalStatus: + """Detect status from the current pyte-composited Codex viewport. + + Codex's existing detector is line-oriented and already understands its + trust/update dialogs, progress spinner, idle composer, and completed + response markers. Remove pyte's blank padding rows and reuse that + detector against the rendered screen; cursor-erased startup frames are + absent here, which prevents a stale spinner from pinning PROCESSING. + """ + rows = [line.rstrip() for line in screen_lines if line.strip()] + if not rows: + return TerminalStatus.UNKNOWN + return self.get_status("\n".join(rows)) + def extract_last_message_from_script(self, script_output: str) -> str: """Extract Codex's final response from terminal output. diff --git a/test/providers/test_codex_provider_unit.py b/test/providers/test_codex_provider_unit.py index 7a7b052d6..eda429044 100644 --- a/test/providers/test_codex_provider_unit.py +++ b/test/providers/test_codex_provider_unit.py @@ -1102,6 +1102,86 @@ def test_get_status_completed_tui_with_status_bar(self): assert status == TerminalStatus.COMPLETED +class TestCodexRenderedScreenStatusDetection: + """Regression coverage for in-place Codex TUI redraws. + + ``tmux pipe-pane`` is append-only: text erased from the visible terminal + remains in CAO's raw rolling buffer. MCP startup uses the same spinner + shape as a live agent turn, so raw parsing can remain PROCESSING forever + after the visible screen has returned to the idle composer. + """ + + def test_provider_opts_into_rendered_screen_detection(self): + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.supports_screen_detection is True + + def test_blank_rendered_screen_is_unknown(self): + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status_from_screen(["", " "]) == TerminalStatus.UNKNOWN + + def test_overwritten_mcp_startup_spinner_does_not_pin_processing(self): + import pyte + + raw = ( + "\x1b[1;1H• Starting MCP servers (0/3): cao-mcp-server" + " (0s • esc to interrupt)" + "\x1b[3;1H› Improve documentation in @filename" + "\x1b[5;1H gpt-5.6-terra high · /tmp/project" + # Codex clears the transient activity row once MCP startup settles. + "\x1b[1;1H\x1b[2K" + ) + screen = pyte.Screen(200, 20) + pyte.Stream(screen).feed(raw) + provider = CodexProvider("test1234", "test-session", "window-0") + + # Demonstrate the old failure mode: stripping cursor controls from the + # append-only stream leaves the erased spinner behind. + assert provider.get_status(raw) == TerminalStatus.PROCESSING + # The composited viewport contains only the live idle composer. + assert provider.get_status_from_screen(list(screen.display)) == TerminalStatus.IDLE + + def test_live_mcp_startup_spinner_is_processing(self): + screen_lines = [ + "• Starting MCP servers (1/3): cao-mcp-server (0s • esc to interrupt)", + "", + "› Improve documentation in @filename", + "", + " gpt-5.6-terra high · /tmp/project", + ] + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status_from_screen(screen_lines) == TerminalStatus.PROCESSING + + @pytest.mark.parametrize("elapsed", ["1m 00s", "1h 00m 00s"]) + def test_minute_plus_live_progress_is_processing(self, elapsed): + screen_lines = [ + "› Implement the requested feature", + f"• Working ({elapsed} • esc to interrupt)", + "", + "› Improve documentation in @filename", + "", + " gpt-5.6-terra high · /tmp/project", + ] + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status_from_screen(screen_lines) == TerminalStatus.PROCESSING + + def test_completed_turn_on_rendered_screen_is_completed(self): + screen_lines = [ + "› Reply with the readiness token", + "• CAO_CODEX_READY", + "", + "› Improve documentation in @filename", + "", + " gpt-5.6-terra high · /tmp/project", + ] + provider = CodexProvider("test1234", "test-session", "window-0") + + assert provider.get_status_from_screen(screen_lines) == TerminalStatus.COMPLETED + + class TestCodexBulletFormatStatusDetection: """Tests for Codex's real interactive output format using › prompt and • bullets."""