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 | |
| parent | 623a1288a7719f53f7829b9fdbab33665e2ccc57 (diff) | |
fix: arbitrate stale agent state evidence
refs #249
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -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 |
9 files changed, 1399 insertions, 51 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 5e1f59bc..e9a4efc9 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Pane apps that query OSC 10/11 default foreground/background colors now receive the host terminal colors, so OpenCode and similar TUIs can detect light terminal themes inside Herdr. (#253) +- Codex Plan mode question prompts now override stale integration `working` reports when the visible terminal UI is clearly waiting for an answer, stale hook authority is cleared when foreground process detection sees Codex exit back to the shell, and Claude Code cancellations now recover from stale hook `working` reports when the idle prompt returns. (#249) - Keybinding parsing now accepts non-ASCII printable keys such as `ö`, `é`, and `ğ`, including UTF-8 Alt chords. (#247) - Kimi Code CLI sessions now use structural terminal detection for approval prompts and live thinking/tool status, improving working and blocked state reporting in the Agents panel. (#215) - Antigravity CLI (`agy`) sessions are now detected, and their terminal UI now reports working and blocked states in the Agents panel. (#207) 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); |
