diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 19:54:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 19:55:07 +0300 |
| commit | 062259ba3e060c7463b4896a6f761cdf66d2f8ff (patch) | |
| tree | df034626fad636e06cc558b3771c3d96914da06f /src/detect.rs | |
| parent | 623a1288a7719f53f7829b9fdbab33665e2ccc57 (diff) | |
fix: arbitrate stale agent state evidence
refs #249
Diffstat (limited to 'src/detect.rs')
| -rw-r--r-- | src/detect.rs | 317 |
1 files changed, 300 insertions, 17 deletions
diff --git a/src/detect.rs b/src/detect.rs index 8479ea70..9b973678 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -16,6 +16,24 @@ pub enum AgentState { Unknown, } +/// Screen-derived agent state plus confidence metadata used for source arbitration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentDetection { + pub state: AgentState, + /// True when the current screen visibly shows live UI chrome that needs + /// human input. This is stronger than arbitrary prompt-like text in the + /// scrollback and may override a non-blocked integration state. + pub visible_blocker: bool, + /// True when the current screen visibly shows the agent's idle input UI. + /// This lets Herdr recover from integrations that miss an interrupt/stop + /// event without treating an empty or ambiguous screen as idle authority. + pub visible_idle: bool, + /// True when the current screen visibly shows live working chrome. This is + /// narrower than a fallback `Working` heuristic and may guard against stale + /// hook idle reports. + pub visible_working: bool, +} + /// Which agent we detected running in a pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { @@ -135,11 +153,22 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag /// Detect the state of an agent from the live terminal tail snapshot. /// If `agent` is `None`, returns `Unknown`. +#[cfg(test)] pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { + detect_agent(agent, screen_content).state +} + +/// Detect state and whether a visible blocker is present on the current screen. +pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetection { let Some(agent) = agent else { - return AgentState::Unknown; + return AgentDetection { + state: AgentState::Unknown, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }; }; - match agent { + let state = match agent { Agent::Pi => detect_pi(screen_content), Agent::Claude => detect_claude(screen_content), Agent::Codex => detect_codex(screen_content), @@ -155,6 +184,12 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { Agent::Amp => detect_amp(screen_content), Agent::Grok => detect_grok(screen_content), Agent::Hermes => detect_hermes(screen_content), + }; + AgentDetection { + state, + visible_blocker: has_visible_blocker(agent, screen_content, state), + visible_idle: has_visible_idle(agent, screen_content, state), + visible_working: has_visible_working(agent, screen_content, state), } } @@ -201,14 +236,7 @@ fn detect_claude(content: &str) -> AgentState { // --- Working detection (content above the prompt box) --- - let above = content_above_prompt_box(content); - let above_lower = above.to_lowercase(); - - if above_lower.contains("esc to interrupt") || above_lower.contains("ctrl+c to interrupt") { - return AgentState::Working; - } - - if has_spinner_activity(above) { + if has_claude_working_chrome(content) { return AgentState::Working; } @@ -221,6 +249,7 @@ fn detect_codex(content: &str) -> AgentState { // Blocked patterns if lower.contains("press enter to confirm or esc to cancel") || lower.contains("enter to submit answer") + || lower.contains("enter to submit all") || lower.contains("allow command?") || lower.contains("[y/n]") || lower.contains("yes (y)") @@ -681,6 +710,86 @@ fn has_spinner_activity(content: &str) -> bool { false } +fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool { + if state != AgentState::Blocked { + return false; + } + + match agent { + // Strong visible blockers are opt-in because this flag can override + // hook authority. Plain blocked heuristics remain valid fallback state, + // but they must not become hook overrides unless the current UI chrome + // is known to be structural and live. + Agent::Claude => has_claude_visible_blocker(content), + Agent::Codex => has_codex_visible_blocker(content), + _ => false, + } +} + +fn has_claude_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("do you want to proceed?") + && has_claude_yes_no_choice(content) + && (lower.contains("bash command") + || lower.contains("bash(") + || lower.contains("contains expansion") + || lower.contains("tab to amend") + || lower.contains("ctrl+e to explain")) +} + +fn has_codex_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("press enter to confirm or esc to cancel") + || lower.contains("enter to submit answer") + || lower.contains("enter to submit all") + || lower.contains("allow command?") +} + +fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool { + if state != AgentState::Idle { + return false; + } + + match agent { + Agent::Claude => has_claude_prompt_box(content), + Agent::Codex => has_codex_prompt(content), + _ => false, + } +} + +fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool { + if state != AgentState::Working { + return false; + } + + match agent { + Agent::Claude => has_claude_working_chrome(content), + Agent::Codex => has_codex_visible_working(content), + _ => false, + } +} + +fn has_codex_visible_working(content: &str) -> bool { + let lines: Vec<&str> = content.lines().collect(); + let Some(working_index) = lines.iter().rposition(|line| { + let trimmed = line.trim_start(); + let lower = trimmed.to_lowercase(); + trimmed.starts_with('•') + && trimmed.contains("Working (") + && (lower.contains("esc to interrupt") || lower.contains("esc…")) + }) else { + return false; + }; + + lines[working_index + 1..].iter().all(|line| { + let trimmed = line.trim_start(); + !trimmed.starts_with('•') + && !trimmed.starts_with('■') + && !trimmed.starts_with('✗') + && !trimmed.starts_with('✓') + }) +} + fn has_codex_working_header(content: &str) -> bool { content.lines().any(|line| { let trimmed = line.trim_start(); @@ -688,6 +797,13 @@ fn has_codex_working_header(content: &str) -> bool { }) } +fn has_codex_prompt(content: &str) -> bool { + content.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed == "›" || trimmed.starts_with("› ") + }) +} + fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool { if lower.contains("waiting for approval") || lower.contains("run this command?") { return true; @@ -799,26 +915,58 @@ fn has_opencode_question_prompt(content: &str) -> bool { lower.contains("esc dismiss") && has_enter_action && has_question_nav } +fn has_claude_working_chrome(content: &str) -> bool { + let above = content_above_prompt_box(content); + let above_lower = above.to_lowercase(); + above_lower.contains("esc to interrupt") + || above_lower.contains("ctrl+c to interrupt") + || has_spinner_activity(above) +} + /// Extract content above Claude's prompt box. /// The prompt box is two ─── border lines with ❯ between them. fn content_above_prompt_box(content: &str) -> &str { let lines: Vec<&str> = content.lines().collect(); + + if let Some(i) = claude_prompt_box_top_border_index(&lines) { + let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum(); + return &content[..byte_offset.min(content.len())]; + } + + // No prompt box found, return all content + content +} + +fn has_claude_prompt_box(content: &str) -> bool { + let lines: Vec<&str> = content.lines().collect(); + let Some(top_border_index) = claude_prompt_box_top_border_index(&lines) else { + return false; + }; + + lines[top_border_index + 1..] + .iter() + .take_while(|line| !is_horizontal_rule(line)) + .any(|line| line.trim_start().starts_with('❯')) +} + +fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> { let mut border_count = 0; for i in (0..lines.len()).rev() { - let trimmed = lines[i].trim(); - if !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') { + if is_horizontal_rule(lines[i]) { border_count += 1; if border_count == 2 { - // Return everything above this border - let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum(); - return &content[..byte_offset.min(content.len())]; + return Some(i); } } } - // No prompt box found, return all content - content + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') } // --------------------------------------------------------------------------- @@ -1402,6 +1550,25 @@ mod tests { } #[test] + fn claude_bash_permission_modal_is_visible_blocker() { + let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + assert!(!detection.visible_idle); + } + + #[test] + fn claude_cropped_bash_permission_modal_is_visible_blocker() { + let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] fn claude_waiting_ask_user_question_menu() { let screen = "Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; @@ -1427,6 +1594,33 @@ mod tests { } #[test] + fn claude_prompt_box_is_visible_idle() { + let screen = "Interrupted.\n─────────────\n❯ \n─────────────"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + } + + #[test] + fn claude_separators_without_prompt_are_not_visible_idle() { + let screen = "Task complete.\n─────────────\nplain text\n─────────────"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Idle); + assert!(!detection.visible_idle); + } + + #[test] + fn claude_spinner_above_prompt_box_is_working() { + let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Working); + assert!(!detection.visible_idle); + } + + #[test] fn claude_idle_search() { let screen = "⌕ Search…\nsome content"; assert_eq!(detect_claude(screen), AgentState::Idle); @@ -1463,6 +1657,42 @@ mod tests { } #[test] + fn codex_question_ui_is_visible_blocker() { + let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn codex_bare_yes_no_hint_is_not_visible_blocker() { + let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts."); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(!detection.visible_blocker); + } + + #[test] + fn codex_generic_confirmation_prompt_is_not_visible_blocker() { + let detection = detect_agent( + Some(Agent::Codex), + "Earlier output asked: do you want to continue? The answer was yes.", + ); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(!detection.visible_blocker); + } + + #[test] + fn non_codex_blocked_heuristics_are_not_strong_visible_blockers_by_default() { + let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No"); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(!detection.visible_blocker); + } + + #[test] fn codex_waiting_submit_answer_wrapped_footer() { assert_eq!( detect_codex( @@ -1473,6 +1703,25 @@ mod tests { } #[test] + fn codex_waiting_submit_all_multi_question_footer() { + let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_interrupted_prompt_is_visible_idle() { + let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert_eq!(detection.state, AgentState::Idle); + assert!(detection.visible_idle); + } + + #[test] fn codex_working_interrupt() { assert_eq!( detect_codex("generating code\nesc to interrupt"), @@ -1486,6 +1735,40 @@ mod tests { } #[test] + fn codex_status_line_is_visible_working() { + let detection = detect_agent( + Some(Agent::Codex), + "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}", + ); + + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_working_header_without_interrupt_is_not_visible_working() { + let detection = detect_agent( + Some(Agent::Codex), + "• Working (17s)\n\n› Implement {feature}", + ); + + assert_eq!(detection.state, AgentState::Working); + assert!(!detection.visible_working); + } + + #[test] + fn codex_old_working_line_before_later_block_is_not_visible_working() { + let detection = detect_agent( + Some(Agent::Codex), + "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}", + ); + + assert_eq!(detection.state, AgentState::Working); + assert!(!detection.visible_working); + } + + #[test] fn codex_idle() { assert_eq!(detect_codex("❯ "), AgentState::Idle); } |
