aboutsummaryrefslogtreecommitdiff
path: root/src/detect/agents/codex.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:54:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:54:41 +0300
commitd477341e6af816ff4396042b084a0378ef437b4a (patch)
treee332b6f7a0d23d9effd21c3ca8ab27698ed83444 /src/detect/agents/codex.rs
parent8df1639048529b383358000879b5ee078a90b9f9 (diff)
refactor: split agent detection by agent
Move screen detection into per-agent modules and keep visible-state arbitration centralized in terminal state. Tighten Codex prompt-scoped working detection and Claude hook/screen recovery for stale blocked and idle reports. Add a focused just recipe for single-test runs. refs #349 refs #352
Diffstat (limited to 'src/detect/agents/codex.rs')
-rw-r--r--src/detect/agents/codex.rs158
1 files changed, 158 insertions, 0 deletions
diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs
new file mode 100644
index 00000000..0d23d4cb
--- /dev/null
+++ b/src/detect/agents/codex.rs
@@ -0,0 +1,158 @@
+use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState};
+
+pub(super) fn detect(content: &str) -> AgentState {
+ let lower = content.to_lowercase();
+
+ // Strong blocked patterns are structural Codex UI chrome, so they can win
+ // even when the prompt region is visible.
+ if has_codex_strong_blocked_prompt(&lower) {
+ return AgentState::Blocked;
+ }
+
+ // Working
+ if has_codex_working_status_at_current_prompt(content) {
+ return AgentState::Working;
+ }
+
+ // Idle
+ if has_codex_current_prompt(content) {
+ return AgentState::Idle;
+ }
+
+ // Weak blocked patterns are too broad to scan above a visible prompt. They
+ // only apply when Codex does not currently show an idle prompt region.
+ if has_codex_weak_blocked_prompt(&lower) {
+ return AgentState::Blocked;
+ }
+
+ // Fallback working signals for narrow captures where the footer scrolled
+ // out or the working row is the only Codex chrome visible.
+ if has_interrupt_pattern(&lower) || has_codex_working_header(content) {
+ return AgentState::Working;
+ }
+
+ AgentState::Idle
+}
+
+pub(super) fn has_visible_blocker(content: &str) -> bool {
+ let lower = content.to_lowercase();
+ has_codex_strong_blocked_prompt(&lower)
+}
+
+pub(super) fn has_prompt(content: &str) -> bool {
+ has_codex_current_prompt(content) || content.lines().any(codex_prompt_line)
+}
+
+pub(super) fn has_visible_working(content: &str) -> bool {
+ has_codex_live_working_at_current_prompt(content)
+ || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content))
+}
+
+fn has_codex_visible_working_without_prompt(content: &str) -> bool {
+ content
+ .lines()
+ .rev()
+ .find(|line| !line.trim().is_empty())
+ .is_some_and(codex_live_working_line)
+}
+
+fn has_codex_strong_blocked_prompt(lower_content: &str) -> bool {
+ lower_content.contains("press enter to confirm or esc to cancel")
+ || lower_content.contains("enter to submit answer")
+ || lower_content.contains("enter to submit all")
+ || lower_content.contains("allow command?")
+}
+
+fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool {
+ lower_content.contains("[y/n]")
+ || lower_content.contains("yes (y)")
+ || has_confirmation_prompt(lower_content)
+}
+
+fn has_codex_live_working_at_current_prompt(content: &str) -> bool {
+ codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line)
+}
+
+fn has_codex_working_status_at_current_prompt(content: &str) -> bool {
+ codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line)
+}
+
+fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> {
+ let (lines, prompt_index) = codex_current_prompt_region(content)?;
+
+ lines[..prompt_index]
+ .iter()
+ .rev()
+ .find(|line| codex_block_marker_line(line))
+ .copied()
+}
+
+fn has_codex_working_header(content: &str) -> bool {
+ content.lines().any(codex_working_status_line)
+}
+
+fn codex_live_working_line(line: &str) -> bool {
+ if codex_queued_input_header_line(line) {
+ return true;
+ }
+
+ let trimmed = line.trim_start();
+ let lower = trimmed.to_lowercase();
+ codex_working_status_line(line)
+ && (trimmed.contains("Waiting for background terminal")
+ || lower.contains("esc to interrupt")
+ || lower.contains("esc…")
+ || lower.contains("background terminal running")
+ || lower.contains("/ps to view")
+ || lower.contains("/stop to close"))
+}
+
+fn codex_working_status_line(line: &str) -> bool {
+ if codex_queued_input_header_line(line) {
+ return true;
+ }
+
+ let trimmed = line.trim_start();
+ trimmed.starts_with('•')
+ && (trimmed.contains("Working (")
+ || trimmed.contains("Waiting for background terminal (")
+ || trimmed.contains("Reviewing approval request (")
+ || trimmed.contains("Booting MCP server:"))
+}
+
+fn has_codex_current_prompt(content: &str) -> bool {
+ codex_current_prompt_region(content).is_some()
+}
+
+fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> {
+ let lines: Vec<&str> = content.lines().collect();
+ let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
+
+ if lines[prompt_index + 1..]
+ .iter()
+ .any(|line| codex_block_marker_line(line))
+ {
+ return None;
+ }
+
+ Some((lines, prompt_index))
+}
+
+fn codex_prompt_line(line: &str) -> bool {
+ line == "›" || line.starts_with("› ")
+}
+
+fn codex_block_marker_line(line: &str) -> bool {
+ line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
+}
+
+fn codex_queued_input_header_line(line: &str) -> bool {
+ let trimmed = line.trim_start();
+ if !trimmed.starts_with('•') {
+ return false;
+ }
+
+ let lower = trimmed.to_lowercase();
+ lower.starts_with("• queued follow-up inputs")
+ || lower.starts_with("• messages to be submitted after next tool call")
+}