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 | |
| parent | 623a1288a7719f53f7829b9fdbab33665e2ccc57 (diff) | |
fix: arbitrate stale agent state evidence
refs #249
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 163 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 5 | ||||
| -rw-r--r-- | src/app/mod.rs | 10 | ||||
| -rw-r--r-- | src/detect.rs | 317 | ||||
| -rw-r--r-- | src/events.rs | 7 | ||||
| -rw-r--r-- | src/pane.rs | 204 | ||||
| -rw-r--r-- | src/terminal/mod.rs | 1 | ||||
| -rw-r--r-- | src/terminal/state.rs | 742 |
8 files changed, 1398 insertions, 51 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index d6b3ae36..ee2e210a 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -876,9 +876,22 @@ impl AppState { pane_id, agent, state, + visible_blocker, + visible_idle, + visible_working, + process_exited, + observed_at, } => self .update_terminal_state(pane_id, |terminal| { - terminal.set_detected_state(agent, state) + terminal.set_detected_state_with_screen_signals_at( + agent, + state, + visible_blocker, + visible_idle, + visible_working, + process_exited, + observed_at, + ) }) .into_iter() .collect(), @@ -1610,6 +1623,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let terminal_id = state.workspaces[0] @@ -1643,6 +1661,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); @@ -1668,6 +1691,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let terminal = state.terminals.get(&terminal_id).unwrap(); @@ -1686,6 +1714,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); @@ -1727,6 +1760,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let toast = state.toast.as_ref().unwrap(); @@ -1759,6 +1797,104 @@ mod tests { } #[test] + fn visible_blocker_overrides_hook_working_and_notifies() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + let bg_terminal_id = state.workspaces[1] + .panes + .get(&bg_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id: bg_pane_id, + source: "herdr:codex".into(), + agent_label: "codex".into(), + state: AgentState::Working, + message: None, + custom_status: None, + seq: Some(1), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Codex), + state: AgentState::Blocked, + visible_blocker: true, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let terminal = state.terminals.get(&bg_terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Blocked); + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::NeedsAttention); + assert_eq!(toast.title, "codex needs attention"); + } + + #[test] + fn visible_idle_waits_before_overriding_claude_hook_working() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + let bg_terminal_id = state.workspaces[1] + .panes + .get(&bg_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Claude), + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id: bg_pane_id, + source: "herdr:claude".into(), + agent_label: "claude".into(), + state: AgentState::Working, + message: None, + custom_status: None, + seq: Some(1), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Claude), + state: AgentState::Idle, + visible_blocker: false, + visible_idle: true, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let terminal = state.terminals.get(&bg_terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Working); + assert!(state.toast.is_none()); + } + + #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); @@ -1776,6 +1912,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Droid), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let toast = state.toast.as_ref().unwrap(); @@ -1801,6 +1942,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let toast = state.toast.as_ref().unwrap(); @@ -1823,6 +1969,11 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); let toast = state.toast.as_ref().unwrap(); @@ -1842,6 +1993,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); assert!(state.toast.is_none()); @@ -1859,6 +2015,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); assert!(state.toast.is_none()); diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 691026de..87c14756 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1723,6 +1723,11 @@ mod tests { pane_id: target_pane, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); diff --git a/src/app/mod.rs b/src/app/mod.rs index a1ae4a74..1eeba25b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2566,6 +2566,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); assert_eq!( app.state.terminals.get(&terminal_id).unwrap().state, @@ -2586,6 +2591,11 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), }); tokio::pin!(send); 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); } diff --git a/src/events.rs b/src/events.rs index 90a65957..4416d00d 100644 --- a/src/events.rs +++ b/src/events.rs @@ -3,6 +3,8 @@ //! Background tasks (PTY child watchers, future hook listeners, etc.) send //! events to the main loop through this channel. No polling needed. +use std::time::Instant; + use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; use crate::workspace::WorkspaceGitStatus; @@ -30,6 +32,11 @@ pub enum AppEvent { pane_id: PaneId, agent: Option<Agent>, state: AgentState, + visible_blocker: bool, + visible_idle: bool, + visible_working: bool, + process_exited: bool, + observed_at: Instant, }, /// Hook-authoritative agent state was reported for a pane. HookStateReported { diff --git a/src/pane.rs b/src/pane.rs index 10112f0f..20eb43da 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -25,7 +25,6 @@ pub use self::{ state::PaneState, terminal::{InputState, ScrollMetrics, TerminalCursorState}, }; -use crate::terminal::stabilize_agent_state; const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); const PANE_TERM: &str = "xterm-256color"; @@ -66,6 +65,11 @@ async fn publish_state_changed_event( pane_id: PaneId, agent: Option<Agent>, state: AgentState, + visible_blocker: bool, + visible_idle: bool, + visible_working: bool, + process_exited: bool, + observed_at: std::time::Instant, ) { // This runs on the async detector task, not the PTY reader thread. // Waiting for queue space here preserves correctness-critical state transitions @@ -75,6 +79,11 @@ async fn publish_state_changed_event( pane_id, agent, state, + visible_blocker, + visible_idle, + visible_working, + process_exited, + observed_at, }) .await { @@ -102,6 +111,57 @@ fn should_clear_agent_for_foreground_shell( previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForegroundShellAgentAction { + ObserveProbe, + ReportProcessExit, + ClearAgent, +} + +fn foreground_shell_agent_action( + previous_agent: Option<Agent>, + new_agent: Option<Agent>, + foreground_is_pane_shell: bool, + process_exit_reported: bool, +) -> ForegroundShellAgentAction { + if !should_clear_agent_for_foreground_shell(previous_agent, new_agent, foreground_is_pane_shell) + { + return ForegroundShellAgentAction::ObserveProbe; + } + + // Do not clear identity immediately. First publish an idle process-exit + // transition for the previous agent so notifications and wait-agent callers + // observe completion before the pane becomes unknown. + if process_exit_reported { + ForegroundShellAgentAction::ClearAgent + } else { + ForegroundShellAgentAction::ReportProcessExit + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DetectionPublishState { + state: AgentState, + visible_blocker: bool, + visible_idle: bool, + visible_working: bool, +} + +fn should_publish_detection_update( + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, +) -> bool { + next.state != previous.state + || next.visible_blocker != previous.visible_blocker + || next.visible_idle != previous.visible_idle + || next.visible_working != previous.visible_working + || agent_changed + || process_exited + || (next.visible_idle && previous.visible_idle) +} + impl AgentDetectionPresence { fn from_agent(current_agent: Option<Agent>) -> Self { Self { @@ -550,7 +610,11 @@ impl PaneRuntime { let mut last_process_check = Instant::now(); let mut last_foreground_pgid = None; let mut pending_foreground_shell_clear = false; + let mut foreground_shell_exit_reported = false; let mut last_claude_working_at = None; + let mut last_visible_blocker = false; + let mut last_visible_idle = false; + let mut last_visible_working = false; tokio::time::sleep(Duration::from_millis(50)).await; @@ -572,7 +636,11 @@ impl PaneRuntime { state = AgentState::Unknown; last_foreground_pgid = None; pending_foreground_shell_clear = false; + foreground_shell_exit_reported = false; last_claude_working_at = None; + last_visible_blocker = false; + last_visible_idle = false; + last_visible_working = false; } } @@ -627,21 +695,26 @@ impl PaneRuntime { } let previous_agent = agent_presence.current_agent(); - let changed = if should_clear_agent_for_foreground_shell( + let changed = match foreground_shell_agent_action( previous_agent, new_agent, foreground_is_pane_shell, + foreground_shell_exit_reported, ) { - if state == AgentState::Idle { - pending_foreground_shell_clear = false; - agent_presence.clear_current_agent() - } else { + ForegroundShellAgentAction::ReportProcessExit => { pending_foreground_shell_clear = true; false } - } else { - pending_foreground_shell_clear = false; - agent_presence.observe_process_probe(new_agent) + ForegroundShellAgentAction::ClearAgent => { + pending_foreground_shell_clear = false; + foreground_shell_exit_reported = false; + agent_presence.clear_current_agent() + } + ForegroundShellAgentAction::ObserveProbe => { + pending_foreground_shell_clear = false; + foreground_shell_exit_reported = false; + agent_presence.observe_process_probe(new_agent) + } }; if new_agent.is_some() { last_foreground_pgid = process_group_id; @@ -683,16 +756,50 @@ impl PaneRuntime { } let content = terminal.detection_text(); - let raw_state = detect::detect_state(agent, &content); - let new_state = stabilize_agent_state( + let process_exited = pending_foreground_shell_clear + && agent.is_some() + && !foreground_shell_exit_reported; + let detection = if process_exited { + detect::AgentDetection { + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + } + } else { + detect::detect_agent(agent, &content) + }; + let raw_state = detection.state; + let new_state = crate::terminal::state::stabilize_agent_detection( agent, state, - raw_state, + detection, + process_exited, now, &mut last_claude_working_at, ); - - if new_state != state || agent_changed { + let visible_blocker = + detection.visible_blocker && new_state == AgentState::Blocked; + let visible_idle = detection.visible_idle && new_state == AgentState::Idle; + let visible_working = + detection.visible_working && new_state == AgentState::Working; + + if should_publish_detection_update( + DetectionPublishState { + state, + visible_blocker: last_visible_blocker, + visible_idle: last_visible_idle, + visible_working: last_visible_working, + }, + DetectionPublishState { + state: new_state, + visible_blocker, + visible_idle, + visible_working, + }, + agent_changed, + process_exited, + ) { debug!( pane = pane_id.raw(), ?state, @@ -702,13 +809,24 @@ impl PaneRuntime { "state changed" ); state = new_state; + last_visible_blocker = visible_blocker; + last_visible_idle = visible_idle; + last_visible_working = visible_working; publish_state_changed_event( state_events.clone(), pane_id, agent, new_state, + visible_blocker, + visible_idle, + visible_working, + process_exited, + now, ) .await; + if process_exited { + foreground_shell_exit_reported = true; + } } } }); @@ -1218,6 +1336,46 @@ mod tests { } #[test] + fn foreground_shell_reports_process_exit_before_clearing_agent() { + assert_eq!( + foreground_shell_agent_action(Some(Agent::Codex), None, true, false), + ForegroundShellAgentAction::ReportProcessExit + ); + assert_eq!( + foreground_shell_agent_action(Some(Agent::Codex), None, true, true), + ForegroundShellAgentAction::ClearAgent + ); + } + + #[test] + fn stable_visible_idle_republishes_for_stale_hook_deadline() { + let previous = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_idle: true, + visible_working: false, + }; + + assert!(should_publish_detection_update( + previous, previous, false, false + )); + } + + #[test] + fn stable_plain_idle_does_not_republish() { + let previous = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }; + + assert!(!should_publish_detection_update( + previous, previous, false, false + )); + } + + #[test] fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() { assert!(!should_clear_agent_for_foreground_shell( Some(Agent::Claude), @@ -1274,8 +1432,17 @@ mod tests { }) .unwrap(); - let publish = - publish_state_changed_event(tx.clone(), pane_id, Some(Agent::Pi), AgentState::Idle); + let publish = publish_state_changed_event( + tx.clone(), + pane_id, + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + std::time::Instant::now(), + ); tokio::pin!(publish); let blocked = tokio::time::timeout(std::time::Duration::from_millis(20), async { @@ -1309,6 +1476,11 @@ mod tests { pane_id: delivered_pane, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + process_exited: false, + observed_at: _, } if delivered_pane == pane_id )); } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 588d5d76..7e43f7a8 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -6,5 +6,4 @@ pub mod state; pub use id::TerminalId; pub use runtime::TerminalRuntime; pub(crate) use runtime_registry::TerminalRuntimeRegistry; -pub(crate) use state::stabilize_agent_state; pub use state::{EffectiveStateChange, TerminalState}; diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 5b2d956d..7f9a254a 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,10 +1,18 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::time::{Duration, Instant}; + +// Effective state arbitration is intentionally centralized here. Hooks are the +// default authority for agent-owned internal state, but a narrow set of strong +// visible screen signals can veto stale non-blocked hook reports. Precedence is: +// hook blocked > strong visible blocker > Claude visible idle > hook > fallback. +// Process-exit updates clear matching hook authority before recomputing state. use crate::detect::{Agent, AgentState}; use crate::terminal::TerminalId; -const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); +const CLAUDE_WORKING_HOLD: Duration = Duration::from_millis(1200); +const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2); #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookAuthority { @@ -13,6 +21,7 @@ pub struct HookAuthority { pub state: AgentState, pub message: Option<String>, pub custom_status: Option<String>, + pub reported_at: Instant, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -36,6 +45,11 @@ pub struct TerminalState { pub cwd: PathBuf, pub detected_agent: Option<Agent>, pub fallback_state: AgentState, + fallback_visible_blocker: bool, + fallback_visible_idle: bool, + fallback_visible_working: bool, + fallback_observed_at: Option<Instant>, + stale_hook_idle_since: Option<Instant>, pub hook_authority: Option<HookAuthority>, pub manual_label: Option<String>, pub agent_name: Option<String>, @@ -52,6 +66,11 @@ impl TerminalState { cwd, detected_agent: None, fallback_state: AgentState::Unknown, + fallback_visible_blocker: false, + fallback_visible_idle: false, + fallback_visible_working: false, + fallback_observed_at: None, + stale_hook_idle_since: None, hook_authority: None, manual_label: None, agent_name: None, @@ -67,28 +86,83 @@ impl TerminalState { self } + #[cfg(test)] pub fn set_detected_state( &mut self, agent: Option<Agent>, fallback_state: AgentState, ) -> Option<EffectiveStateChange> { + self.set_detected_state_with_visible_blocker(agent, fallback_state, false, false, false) + } + + #[cfg(test)] + pub fn set_detected_state_with_visible_blocker( + &mut self, + agent: Option<Agent>, + fallback_state: AgentState, + visible_blocker: bool, + visible_idle: bool, + process_exited: bool, + ) -> Option<EffectiveStateChange> { + self.set_detected_state_with_screen_signals_at( + agent, + fallback_state, + visible_blocker, + visible_idle, + false, + process_exited, + Instant::now(), + ) + } + + pub fn set_detected_state_with_screen_signals_at( + &mut self, + agent: Option<Agent>, + fallback_state: AgentState, + visible_blocker: bool, + visible_idle: bool, + visible_working: bool, + process_exited: bool, + now: Instant, + ) -> Option<EffectiveStateChange> { let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; let previous_detected_agent = self.detected_agent; self.detected_agent = agent; self.fallback_state = fallback_state; - if self.hook_authority_conflicts_with_detected_agent(agent) - || (previous_detected_agent.is_some() - && agent != previous_detected_agent - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) - == previous_detected_agent - })) + self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; + self.fallback_visible_idle = visible_idle && fallback_state == AgentState::Idle; + self.fallback_visible_working = visible_working && fallback_state == AgentState::Working; + self.fallback_observed_at = Some(now); + if process_exited + && self.hook_authority_not_newer_than(now) + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) == agent + }) + { + self.hook_authority = None; + self.stale_hook_idle_since = None; + } + if self.hook_authority_not_newer_than(now) + && (self.hook_authority_conflicts_with_detected_agent(agent) + || (previous_detected_agent.is_some() + && agent != previous_detected_agent + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) + == previous_detected_agent + }))) { self.hook_authority = None; + self.stale_hook_idle_since = None; } - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + self.update_stale_hook_idle_window(now); + self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ) } #[cfg(test)] @@ -112,6 +186,27 @@ impl TerminalState { custom_status: Option<String>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { + self.set_hook_authority_with_custom_status_at( + source, + agent_label, + state, + message, + custom_status, + seq, + Instant::now(), + ) + } + + pub fn set_hook_authority_with_custom_status_at( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + custom_status: Option<String>, + seq: Option<u64>, + now: Instant, + ) -> Option<EffectiveStateChange> { if !self.accept_hook_report(&source, seq) { return None; } @@ -128,8 +223,28 @@ impl TerminalState { state, message, custom_status, + reported_at: now, }); - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + self.stale_hook_idle_since = None; + self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ) + } + + fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool { + self.hook_authority + .as_ref() + .is_none_or(|authority| authority.reported_at <= observed_at) + } + + fn fallback_not_older_than_hook(&self) -> bool { + self.hook_authority.as_ref().is_none_or(|authority| { + self.fallback_observed_at + .is_some_and(|observed_at| authority.reported_at <= observed_at) + }) } fn hook_authority_conflicts_with_detected_agent(&self, detected_agent: Option<Agent>) -> bool { @@ -194,7 +309,13 @@ impl TerminalState { return None; } self.hook_authority = None; - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + self.stale_hook_idle_since = None; + self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + Instant::now(), + ) } pub fn release_agent( @@ -223,8 +344,18 @@ impl TerminalState { let previous_state = self.state; self.detected_agent = None; self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_visible_idle = false; + self.fallback_visible_working = false; + self.fallback_observed_at = None; self.hook_authority = None; - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + self.stale_hook_idle_since = None; + self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + Instant::now(), + ) } pub fn effective_agent_label(&self) -> Option<&str> { @@ -242,11 +373,69 @@ impl TerminalState { } pub fn effective_custom_status(&self) -> Option<&str> { + if self.visible_blocker_overrides_hook() + || self.visible_idle_masks_hook_custom_status(self.state, Instant::now()) + { + return None; + } + self.hook_authority .as_ref() .and_then(|authority| authority.custom_status.as_deref()) } + fn visible_blocker_overrides_hook(&self) -> bool { + self.fallback_visible_blocker + && self.fallback_not_older_than_hook() + && self.hook_authority.as_ref().is_some_and(|authority| { + authority.state != AgentState::Blocked + && crate::detect::parse_agent_label(&authority.agent_label) + == self.detected_agent + }) + } + + fn visible_working_overrides_hook(&self) -> bool { + self.fallback_visible_working + && self.fallback_not_older_than_hook() + && self.hook_authority.as_ref().is_some_and(|authority| { + authority.state == AgentState::Idle + && crate::detect::parse_agent_label(&authority.agent_label) + == self.detected_agent + }) + } + + fn visible_idle_stales_hook(&self, now: Instant) -> bool { + self.stale_hook_idle_since + .is_some_and(|since| now.duration_since(since) >= STALE_HOOK_IDLE_GRACE) + } + + fn visible_idle_masks_hook_custom_status(&self, state: AgentState, now: Instant) -> bool { + self.fallback_visible_idle + && self.fallback_not_older_than_hook() + && self.hook_authority.as_ref().is_some_and(|authority| { + authority.state == AgentState::Working + && crate::detect::parse_agent_label(&authority.agent_label) + == self.detected_agent + }) + && (state == AgentState::Idle || self.visible_idle_stales_hook(now)) + } + + fn update_stale_hook_idle_window(&mut self, now: Instant) { + let visible_idle_stales_hook = self.fallback_visible_idle + && self.fallback_not_older_than_hook() + && self.hook_authority.as_ref().is_some_and(|authority| { + authority.state == AgentState::Working + && crate::detect::parse_agent_label(&authority.agent_label) + == self.detected_agent + }); + + if visible_idle_stales_hook { + self.stale_hook_idle_since.get_or_insert(now); + } else { + self.stale_hook_idle_since = None; + } + } + pub fn set_manual_label(&mut self, label: String) { let label = label.trim().to_string(); self.manual_label = (!label.is_empty()).then_some(label); @@ -284,16 +473,38 @@ impl TerminalState { previous_agent_label: Option<String>, previous_known_agent: Option<Agent>, previous_state: AgentState, + now: Instant, ) -> Option<EffectiveStateChange> { - let state = self + let state = if self .hook_authority .as_ref() - .map(|authority| authority.state) - .unwrap_or(self.fallback_state); + .is_some_and(|authority| authority.state == AgentState::Blocked) + || self.visible_blocker_overrides_hook() + { + AgentState::Blocked + } else if self.visible_working_overrides_hook() { + AgentState::Working + } else if self.visible_idle_stales_hook(now) { + AgentState::Idle + } else { + self.hook_authority + .as_ref() + .map(|authority| authority.state) + .unwrap_or(self.fallback_state) + }; let agent_label = self.effective_agent_label().map(str::to_string); let known_agent = self.effective_known_agent(); - let custom_status = self.effective_custom_status().map(str::to_string); + let custom_status = if self.visible_blocker_overrides_hook() + || self.visible_idle_masks_hook_custom_status(state, now) + { + None + } else { + self.hook_authority + .as_ref() + .and_then(|authority| authority.custom_status.as_deref()) + .map(str::to_string) + }; if previous_agent_label == agent_label && previous_state == state { return None; @@ -342,9 +553,31 @@ pub(crate) fn stabilize_agent_state( } } +pub(crate) fn stabilize_agent_detection( + agent: Option<Agent>, + previous: AgentState, + detection: crate::detect::AgentDetection, + process_exited: bool, + now: std::time::Instant, + last_claude_working_at: &mut Option<std::time::Instant>, +) -> AgentState { + if process_exited { + return detection.state; + } + + stabilize_agent_state( + agent, + previous, + detection.state, + now, + last_claude_working_at, + ) +} + #[cfg(test)] mod tests { use super::*; + use crate::detect::AgentDetection; fn test_terminal() -> TerminalState { TerminalState::new(TerminalId::alloc(), "/tmp".into()) @@ -390,6 +623,50 @@ mod tests { } #[test] + fn process_exit_idle_bypasses_claude_working_hold() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_detection( + Some(Agent::Claude), + AgentState::Working, + AgentDetection { + state: AgentState::Idle, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }, + true, + now + std::time::Duration::from_millis(100), + &mut last_working, + ); + + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn visible_idle_does_not_bypass_claude_working_hold() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_detection( + Some(Agent::Claude), + AgentState::Working, + AgentDetection { + state: AgentState::Idle, + visible_blocker: false, + visible_idle: true, + visible_working: false, + }, + false, + now + std::time::Duration::from_millis(100), + &mut last_working, + ); + + assert_eq!(state, AgentState::Working); + } + + #[test] fn non_claude_states_are_unchanged() { let now = std::time::Instant::now(); let mut last_working = None; @@ -441,6 +718,308 @@ mod tests { } #[test] + fn visible_blocker_overrides_non_blocked_hook_for_same_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Blocked); + assert_eq!(terminal.state, AgentState::Blocked); + assert_eq!(change.unwrap().previous_state, AgentState::Working); + } + + #[test] + fn weak_blocked_fallback_does_not_override_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Blocked, + false, + false, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Blocked); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); + } + + #[test] + fn hook_blocked_wins_over_visible_blocker() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Blocked, + None, + None, + ); + + terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.state, AgentState::Blocked); + assert!(terminal.hook_authority.is_some()); + } + + #[test] + fn visible_blocker_does_not_override_different_agent_hook() { + let mut terminal = test_terminal(); + terminal.set_detected_state(None, AgentState::Unknown); + terminal.set_hook_authority( + "custom:agent".into(), + "custom-agent".into(), + AgentState::Working, + None, + None, + ); + + terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn visible_blocker_suppresses_stale_hook_custom_status() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_hook_authority_with_custom_status( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + Some("planning".into()), + None, + ); + + terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.state, AgentState::Blocked); + assert_eq!(terminal.effective_custom_status(), None); + } + + #[test] + fn visible_idle_waits_before_overriding_claude_hook_working() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Working, + false, + false, + true, + false, + now, + ); + terminal.set_hook_authority_with_custom_status_at( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + Some("thinking".into()), + None, + now, + ); + + let waiting = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(500), + ); + + assert!(waiting.is_none()); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.effective_custom_status(), Some("thinking")); + + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), + ); + + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(change.unwrap().previous_state, AgentState::Working); + } + + #[test] + fn fresh_hook_working_resets_visible_idle_stale_window() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); + terminal.set_hook_authority_with_custom_status_at( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + Some("thinking".into()), + None, + now, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + false, + now + Duration::from_millis(500), + ); + + terminal.set_hook_authority_with_custom_status_at( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + Some("thinking".into()), + Some(1), + now + Duration::from_millis(800), + ); + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + false, + now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), + ); + + assert!(change.is_none()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn visible_working_overrides_hook_idle_for_same_agent() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:claude".into(), + "claude".into(), + AgentState::Idle, + None, + None, + None, + now, + ); + + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Working, + false, + false, + true, + false, + now + Duration::from_millis(1), + ); + + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(change.unwrap().previous_state, AgentState::Idle); + } + + #[test] + fn visible_idle_does_not_override_hook_blocked() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); + terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Blocked, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Blocked); + assert!(change.is_none()); + } + + #[test] + fn visible_idle_does_not_override_other_agent_hook_working() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Idle, + false, + true, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); + } + + #[test] fn known_hook_authority_does_not_override_different_detected_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); @@ -540,6 +1119,137 @@ mod tests { } #[test] + fn detected_agent_clear_clears_matching_working_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + ); + + terminal.set_detected_state(None, AgentState::Unknown); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.effective_agent_label(), None); + assert_eq!(terminal.state, AgentState::Unknown); + } + + #[test] + fn process_exit_clears_matching_hook_authority_before_reporting_idle() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + ); + + terminal.set_detected_state_with_visible_blocker( + Some(Agent::Codex), + AgentState::Idle, + false, + false, + true, + ); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Codex)); + assert_eq!(terminal.effective_agent_label(), Some("codex")); + assert_eq!(terminal.state, AgentState::Idle); + } + + #[test] + fn stale_visible_screen_signal_does_not_override_newer_hook_authority() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Working, + false, + false, + true, + false, + observed, + ); + terminal.set_hook_authority_with_custom_status_at( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + None, + Some(1), + observed + Duration::from_secs(1), + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Claude), + AgentState::Idle, + false, + true, + false, + false, + observed, + ); + + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.stale_hook_idle_since.is_none()); + } + + #[test] + fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Working, + false, + false, + false, + false, + observed, + ); + terminal.set_hook_authority_with_custom_status_at( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + Some(1), + observed, + ); + terminal.set_hook_authority_with_custom_status_at( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + Some("new turn".into()), + Some(2), + observed + Duration::from_secs(1), + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Idle, + false, + false, + false, + true, + observed, + ); + + let authority = terminal.hook_authority.as_ref().expect("hook authority"); + assert_eq!(authority.custom_status.as_deref(), Some("new turn")); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.effective_agent_label(), Some("codex")); + } + + #[test] fn detected_agent_change_clears_previous_matching_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); |
