diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/pane.rs | 1515 | ||||
| -rw-r--r-- | src/pane/agent_detection.rs | 1886 |
2 files changed, 2175 insertions, 1226 deletions
diff --git a/src/pane.rs b/src/pane.rs index baa5790c..f1d1c699 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -21,6 +21,7 @@ use crate::events::AppEvent; use crate::layout::PaneId; use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult}; +mod agent_detection; mod input; mod kitty_keyboard; mod osc; @@ -28,6 +29,14 @@ mod state; mod terminal; mod xtgettcap; +use self::agent_detection::{ + agent_caused_pty_activity_active, baseline_pty_causality, decide_detection_screen_read, + decide_screen_detection_publish, detection_update_for_publish, handle_skipped_detection_update, + observe_pty_output_activity, DetectionPublishDecision, DetectionScreenReadDecision, + DetectionScreenReadInput, PendingIdleConfirmation, PendingWorkingConfirmation, + PostTaintWorkingLease, PtyCausalityTracker, ScreenDetectionPublishInput, + AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, +}; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome}; pub use self::{ @@ -108,6 +117,50 @@ async fn publish_state_changed_event( } } +#[derive(Debug, Clone, Copy)] +struct AgentDetectionPublishUpdate { + state: AgentState, + visible_blocker: bool, + visible_working: bool, + process_exited: bool, +} + +async fn apply_agent_detection_publish_update( + state_events: mpsc::Sender<AppEvent>, + pane_id: PaneId, + agent: Option<Agent>, + update: AgentDetectionPublishUpdate, + observed_at: std::time::Instant, + state: &mut AgentState, + last_visible_blocker: &mut bool, + last_visible_working: &mut bool, + last_visible_signal_refresh: &mut Option<std::time::Instant>, + foreground_shell_exit_reported: &mut bool, +) { + *state = update.state; + *last_visible_blocker = update.visible_blocker; + *last_visible_working = update.visible_working; + *last_visible_signal_refresh = if update.visible_blocker || update.visible_working { + Some(observed_at) + } else { + None + }; + if update.process_exited { + *foreground_shell_exit_reported = true; + } + publish_state_changed_event( + state_events, + pane_id, + agent, + update.state, + update.visible_blocker, + update.visible_working, + update.process_exited, + observed_at, + ) + .await; +} + const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5); const PROCESS_RECHECK_MISSING_FOREGROUND_GROUP: std::time::Duration = @@ -117,22 +170,6 @@ const PROCESS_ACQUISITION_FAST_WINDOW: std::time::Duration = std::time::Duration const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duration::from_millis(500); const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); const PROCESS_ACQUISITION_IDLE_RESET: std::time::Duration = std::time::Duration::from_secs(2); -const STABLE_VISIBLE_SIGNAL_REFRESH: std::time::Duration = std::time::Duration::from_millis(800); -const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration = std::time::Duration::from_millis(1800); -const AGENT_INPUT_TAINT_WINDOW: std::time::Duration = std::time::Duration::from_millis(1200); -const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); -const AGENT_PENDING_IDLE_RECHECK: std::time::Duration = std::time::Duration::from_millis(100); -const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3; -const AGENT_PENDING_IDLE_CAP: std::time::Duration = std::time::Duration::from_millis(700); -const AGENT_PENDING_WORKING_FAST_RECHECK: std::time::Duration = - std::time::Duration::from_millis(100); -const AGENT_PENDING_WORKING_SLOW_RECHECK: std::time::Duration = - std::time::Duration::from_millis(250); -const AGENT_PENDING_WORKING_FAST_WINDOW: std::time::Duration = - std::time::Duration::from_millis(500); -const AGENT_PENDING_WORKING_CAP: std::time::Duration = std::time::Duration::from_secs(2); -const AGENT_PENDING_WORKING_CONFIRM_DELAY: std::time::Duration = - std::time::Duration::from_millis(250); #[derive(Debug, Clone, Copy)] struct AgentDetectionPresence { @@ -337,307 +374,6 @@ fn probe_foreground_process(pid: u32, foreground_pgid: Option<u32>) -> ProcessPr } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DetectionPublishState { - state: AgentState, - visible_blocker: bool, - visible_working: bool, -} - -#[derive(Debug, Default)] -struct PendingIdleConfirmation { - started_at: Option<std::time::Instant>, - confirmations: u8, -} - -impl PendingIdleConfirmation { - fn active(&self) -> bool { - self.started_at.is_some() - } - - fn clear(&mut self) { - self.started_at = None; - self.confirmations = 0; - } - - fn should_hold_working_to_idle( - &mut self, - previous: DetectionPublishState, - next: DetectionPublishState, - agent_changed: bool, - process_exited: bool, - pty_signal: Option<PtyActivitySignal>, - now: std::time::Instant, - ) -> bool { - let is_working_to_plain_idle = previous.state == AgentState::Working - && next.state == AgentState::Idle - && !next.visible_blocker - && !agent_changed - && !process_exited; - - if !is_working_to_plain_idle { - self.clear(); - return false; - } - - if pty_signal.is_some_and(|signal| !signal.active && !signal.tainted) { - self.clear(); - return false; - } - - let Some(started_at) = self.started_at else { - self.started_at = Some(now); - self.confirmations = 0; - return true; - }; - - if now.duration_since(started_at) >= AGENT_PENDING_IDLE_CAP { - self.clear(); - return false; - } - - self.confirmations = self.confirmations.saturating_add(1); - if self.confirmations >= AGENT_PENDING_IDLE_CONFIRMATIONS { - self.clear(); - return false; - } - - true - } -} - -#[derive(Debug, Default)] -struct PendingWorkingConfirmation { - started_at: Option<std::time::Instant>, - last_observed_output_seq: u64, -} - -impl PendingWorkingConfirmation { - fn active(&self) -> bool { - self.started_at.is_some() - } - - fn clear(&mut self) { - self.started_at = None; - self.last_observed_output_seq = 0; - } - - fn recheck_delay(&self, now: std::time::Instant) -> std::time::Duration { - let Some(started_at) = self.started_at else { - return AGENT_PENDING_WORKING_FAST_RECHECK; - }; - if now.duration_since(started_at) < AGENT_PENDING_WORKING_FAST_WINDOW { - AGENT_PENDING_WORKING_FAST_RECHECK - } else { - AGENT_PENDING_WORKING_SLOW_RECHECK - } - } - - fn should_hold_idle_to_working( - &mut self, - previous: DetectionPublishState, - next: DetectionPublishState, - agent_changed: bool, - process_exited: bool, - pty_signal: Option<PtyActivitySignal>, - now: std::time::Instant, - ) -> bool { - let is_idle_to_working = previous.state == AgentState::Idle - && next.state == AgentState::Working - && !next.visible_blocker - && !agent_changed - && !process_exited; - - if !is_idle_to_working { - self.clear(); - return false; - } - - let Some(pty_signal) = pty_signal else { - self.clear(); - return false; - }; - if !pty_signal.active { - self.clear(); - return false; - } - - let Some(started_at) = self.started_at else { - self.started_at = Some(now); - self.last_observed_output_seq = pty_signal.output_seq; - return true; - }; - - if pty_signal.fresh_output && pty_signal.output_seq != self.last_observed_output_seq { - self.last_observed_output_seq = pty_signal.output_seq; - if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CONFIRM_DELAY { - self.clear(); - return false; - } - } - - if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CAP { - self.clear(); - return true; - } - - true - } - - fn should_publish_held_working_before_exit( - &mut self, - previous: DetectionPublishState, - next: DetectionPublishState, - process_exited: bool, - ) -> bool { - if self.started_at.is_none() - || !process_exited - || previous.state != AgentState::Idle - || next.state != AgentState::Idle - { - return false; - } - - self.clear(); - true - } -} - -fn pty_working_transition_is_vetoed( - agent: Option<Agent>, - previous: DetectionPublishState, - next: DetectionPublishState, - content: &str, -) -> bool { - previous.state == AgentState::Idle - && next.state == AgentState::Working - && !next.visible_blocker - && crate::detect::agent_activity_veto(agent, content).is_some() -} - -fn should_publish_detection_update( - previous: DetectionPublishState, - next: DetectionPublishState, - agent_changed: bool, - process_exited: bool, - stable_visible_signal_refresh_due: bool, -) -> bool { - next.state != previous.state - || next.visible_blocker != previous.visible_blocker - || next.visible_working != previous.visible_working - || agent_changed - || process_exited - || (stable_visible_signal_refresh_due && next.visible_blocker && previous.visible_blocker) -} - -fn stable_visible_signal_refresh_due( - previous: DetectionPublishState, - next: DetectionPublishState, - last_refresh: Option<std::time::Instant>, - now: std::time::Instant, -) -> bool { - let stable_visible_signal = next.visible_blocker && previous.visible_blocker; - - stable_visible_signal - && last_refresh.is_none_or(|last_refresh| { - now.duration_since(last_refresh) >= STABLE_VISIBLE_SIGNAL_REFRESH - }) -} - -fn detection_update_for_publish( - agent: Option<Agent>, - content: &str, - process_exited: bool, -) -> Option<crate::detect::AgentDetection> { - if crate::detect::should_skip_state_update(agent, content) { - return None; - } - - if process_exited { - return Some(crate::detect::AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_working: false, - }); - } - - let detection = crate::detect::detect_agent(agent, content); - (!detection.skip_state_update).then_some(detection) -} - -#[derive(Debug, Default)] -struct PtyCausalityTracker { - last_pty_output_seq: u64, - last_input_seq: u64, - input_tainted_until: Option<std::time::Instant>, - last_agent_pty_at: Option<std::time::Instant>, -} - -#[derive(Debug, Clone, Copy)] -struct PtyActivitySignal { - active: bool, - tainted: bool, - fresh_output: bool, - output_seq: u64, -} - -fn baseline_pty_causality(tracker: &mut PtyCausalityTracker, pty_output_seq: u64, input_seq: u64) { - tracker.last_pty_output_seq = pty_output_seq; - tracker.last_input_seq = input_seq; - tracker.input_tainted_until = None; - tracker.last_agent_pty_at = None; -} - -fn agent_caused_pty_activity_active( - pty_output_seq: u64, - input_seq: u64, - tracker: &mut PtyCausalityTracker, - now: std::time::Instant, -) -> PtyActivitySignal { - if input_seq != tracker.last_input_seq { - tracker.last_input_seq = input_seq; - tracker.input_tainted_until = Some(now + AGENT_INPUT_TAINT_WINDOW); - tracker.last_agent_pty_at = None; - } - - let tainted = tracker.input_tainted_until.is_some_and(|until| now < until); - - let mut fresh_output = false; - if pty_output_seq != tracker.last_pty_output_seq { - tracker.last_pty_output_seq = pty_output_seq; - if !tainted { - tracker.last_agent_pty_at = Some(now); - fresh_output = true; - } - } - - if tainted { - return PtyActivitySignal { - active: false, - tainted: true, - fresh_output: false, - output_seq: tracker.last_pty_output_seq, - }; - } - - let active = tracker - .last_agent_pty_at - .is_some_and(|last| now.duration_since(last) < AGENT_PTY_ACTIVITY_WINDOW); - PtyActivitySignal { - active, - tainted: false, - fresh_output, - output_seq: tracker.last_pty_output_seq, - } -} - -fn observe_pty_output_activity(bytes: &[u8], pty_output_seq: &AtomicU64) { - if !bytes.is_empty() { - pty_output_seq.fetch_add(1, Ordering::Relaxed); - } -} - #[cfg(unix)] fn spawn_basic_detection_task( pane_id: PaneId, @@ -671,10 +407,12 @@ fn spawn_basic_detection_task( let mut foreground_shell_exit_reported = false; let mut release_was_active = false; let mut last_detection_text = String::new(); + let mut last_screen_scan_pty_output_seq = None; let mut pty_causality = PtyCausalityTracker::default(); let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); loop { let now_for_sleep = std::time::Instant::now(); @@ -702,10 +440,12 @@ fn spawn_basic_detection_task( foreground_shell_exit_reported = false; release_was_active = false; last_detection_text.clear(); + last_screen_scan_pty_output_seq = None; pty_causality = PtyCausalityTracker::default(); agent_startup_grace_until = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); } } @@ -794,6 +534,8 @@ fn spawn_basic_detection_task( if agent_changed { pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); + last_screen_scan_pty_output_seq = None; if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); baseline_pty_causality( @@ -832,10 +574,12 @@ fn spawn_basic_detection_task( agent_startup_grace_until = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); } else { if now < until { pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); continue; } baseline_pty_causality( @@ -844,16 +588,79 @@ fn spawn_basic_detection_task( input_write_seq.load(Ordering::Relaxed), ); agent_startup_grace_until = None; + last_screen_scan_pty_output_seq = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); + continue; + } + } + + let pty_activity = if agent.is_some() { + Some(agent_caused_pty_activity_active( + pty_output_seq.load(Ordering::Relaxed), + input_write_seq.load(Ordering::Relaxed), + &mut pty_causality, + now, + )) + } else { + None + }; + match decide_detection_screen_read(DetectionScreenReadInput { + state, + agent, + pending_idle_active: pending_idle.active(), + pending_working_active: pending_working.active(), + post_taint_working_active: post_taint_working.active(), + agent_changed, + process_exited, + pty_activity, + last_screen_scan_pty_output_seq, + }) { + DetectionScreenReadDecision::Read => {} + DetectionScreenReadDecision::Skip => continue, + DetectionScreenReadDecision::Publish { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + } => { + apply_agent_detection_publish_update( + state_events.clone(), + pane_id, + agent, + AgentDetectionPublishUpdate { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + }, + now, + &mut state, + &mut last_visible_blocker, + &mut last_visible_working, + &mut last_visible_signal_refresh, + &mut foreground_shell_exit_reported, + ) + .await; continue; } } let content = terminal.detection_text(); + last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq); let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); if !process_exited && crate::detect::should_skip_state_update(agent, &content) { + handle_skipped_detection_update( + state, + pty_activity, + &mut post_taint_working, + &mut pty_causality, + pty_output_seq.load(Ordering::Relaxed), + input_write_seq.load(Ordering::Relaxed), + now, + ); pending_idle.clear(); pending_working.clear(); continue; @@ -871,149 +678,62 @@ fn spawn_basic_detection_task( let Some(screen_detection) = detection_update_for_publish(agent, &content, process_exited) else { - pending_idle.clear(); - pending_working.clear(); - continue; - }; - let pty_activity = if agent.is_some() { - Some(agent_caused_pty_activity_active( + handle_skipped_detection_update( + state, + pty_activity, + &mut post_taint_working, + &mut pty_causality, pty_output_seq.load(Ordering::Relaxed), input_write_seq.load(Ordering::Relaxed), - &mut pty_causality, now, - )) - } else { - None - }; - let pty_signal = if let Some(signal) = pty_activity { - Some(crate::agent_detection_policy::PtySignal { - active: signal.active, - tainted: signal.tainted, - }) - } else { - None - }; - let detection = match crate::agent_detection_policy::apply_detection_policy( - crate::agent_detection_policy::DetectionPolicyInput { - agent, - screen_detection, - process_exited, - startup_grace_active: false, - pty_signal, - }, - ) { - crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => { - detection - } - crate::agent_detection_policy::DetectionPolicyDecision::Freeze => { - pending_idle.clear(); - pending_working.clear(); - continue; - } - }; - let new_state = crate::terminal::state::stabilize_agent_detection(detection); - let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; - let visible_working = detection.visible_working && new_state == AgentState::Working; - - let previous_publish = DetectionPublishState { - state, - visible_blocker: last_visible_blocker, - visible_working: last_visible_working, - }; - let next_publish = DetectionPublishState { - state: new_state, - visible_blocker, - visible_working, - }; - let stable_refresh_due = stable_visible_signal_refresh_due( - previous_publish, - next_publish, - last_visible_signal_refresh, - now, - ); - - if pty_working_transition_is_vetoed(agent, previous_publish, next_publish, &content) { + ); pending_idle.clear(); pending_working.clear(); continue; - } - - if pending_working.should_publish_held_working_before_exit( - previous_publish, - next_publish, - process_exited, - ) { - pending_idle.clear(); - state = AgentState::Working; - last_visible_blocker = false; - last_visible_working = false; - last_visible_signal_refresh = None; - publish_state_changed_event( - state_events.clone(), - pane_id, + }; + match decide_screen_detection_publish( + ScreenDetectionPublishInput { agent, - AgentState::Working, - false, - false, - false, + screen_detection, + current_state: state, + last_visible_blocker, + last_visible_working, + last_visible_signal_refresh, + process_exited, + agent_changed, + pty_activity, + content: &content, now, - ) - .await; - continue; - } - - if pending_working.should_hold_idle_to_working( - previous_publish, - next_publish, - agent_changed, - process_exited, - pty_activity, - now, - ) { - pending_idle.clear(); - continue; - } - - if pending_idle.should_hold_working_to_idle( - previous_publish, - next_publish, - agent_changed, - process_exited, - pty_activity, - now, - ) { - pending_working.clear(); - continue; - } - - if should_publish_detection_update( - previous_publish, - next_publish, - agent_changed, - process_exited, - stable_refresh_due, + }, + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, ) { - state = new_state; - last_visible_blocker = visible_blocker; - last_visible_working = visible_working; - if visible_blocker || visible_working { - last_visible_signal_refresh = Some(now); - } else { - last_visible_signal_refresh = None; - } - publish_state_changed_event( - state_events.clone(), - pane_id, - agent, - new_state, + DetectionPublishDecision::NoPublish => {} + DetectionPublishDecision::Publish { + state: new_state, visible_blocker, visible_working, - process_exited, - now, - ) - .await; - if process_exited { - foreground_shell_exit_reported = true; + process_exited: publish_process_exited, + } => { + apply_agent_detection_publish_update( + state_events.clone(), + pane_id, + agent, + AgentDetectionPublishUpdate { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + }, + now, + &mut state, + &mut last_visible_blocker, + &mut last_visible_working, + &mut last_visible_signal_refresh, + &mut foreground_shell_exit_reported, + ) + .await; } } } @@ -1848,10 +1568,8 @@ impl PaneRuntime { let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); - if result.request_render { - if !render_dirty.swap(true, Ordering::AcqRel) { - render_notify.notify_one(); - } + if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { + render_notify.notify_one(); } if let Some(delay) = result.render_delay { let render_notify = render_notify.clone(); @@ -2003,10 +1721,8 @@ impl PaneRuntime { let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); - if result.request_render { - if !render_dirty.swap(true, Ordering::AcqRel) { - render_notify.notify_one(); - } + if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { + render_notify.notify_one(); } if let Some(delay) = result.render_delay { let render_notify = render_notify.clone(); @@ -2084,10 +1800,12 @@ impl PaneRuntime { let mut last_visible_working = false; let mut last_visible_signal_refresh = None; let mut last_detection_text = String::new(); + let mut last_screen_scan_pty_output_seq = None; let mut pty_causality = PtyCausalityTracker::default(); let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); tokio::time::sleep(Duration::from_millis(50)).await; @@ -2124,10 +1842,12 @@ impl PaneRuntime { last_visible_working = false; last_visible_signal_refresh = None; last_detection_text.clear(); + last_screen_scan_pty_output_seq = None; pty_causality = PtyCausalityTracker::default(); agent_startup_grace_until = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); } } @@ -2223,6 +1943,8 @@ impl PaneRuntime { if agent != previous_agent { pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); + last_screen_scan_pty_output_seq = None; if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); @@ -2289,12 +2011,15 @@ impl PaneRuntime { if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; + last_screen_scan_pty_output_seq = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); } else { if now < until { pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); continue; } baseline_pty_causality( @@ -2305,14 +2030,76 @@ impl PaneRuntime { agent_startup_grace_until = None; pending_idle.clear(); pending_working.clear(); + post_taint_working.clear(); + continue; + } + } + + let pty_activity = if agent.is_some() { + Some(agent_caused_pty_activity_active( + pty_output_seq.load(Ordering::Relaxed), + input_write_seq_for_task.load(Ordering::Relaxed), + &mut pty_causality, + now, + )) + } else { + None + }; + match decide_detection_screen_read(DetectionScreenReadInput { + state, + agent, + pending_idle_active: pending_idle.active(), + pending_working_active: pending_working.active(), + post_taint_working_active: post_taint_working.active(), + agent_changed, + process_exited, + pty_activity, + last_screen_scan_pty_output_seq, + }) { + DetectionScreenReadDecision::Read => {} + DetectionScreenReadDecision::Skip => continue, + DetectionScreenReadDecision::Publish { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + } => { + apply_agent_detection_publish_update( + state_events.clone(), + pane_id, + agent, + AgentDetectionPublishUpdate { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + }, + now, + &mut state, + &mut last_visible_blocker, + &mut last_visible_working, + &mut last_visible_signal_refresh, + &mut foreground_shell_exit_reported, + ) + .await; continue; } } let content = terminal.detection_text(); + last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq); let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); if detect::should_skip_state_update(agent, &content) { + handle_skipped_detection_update( + state, + pty_activity, + &mut post_taint_working, + &mut pty_causality, + pty_output_seq.load(Ordering::Relaxed), + input_write_seq_for_task.load(Ordering::Relaxed), + now, + ); pending_idle.clear(); pending_working.clear(); continue; @@ -2330,156 +2117,62 @@ impl PaneRuntime { let Some(screen_detection) = detection_update_for_publish(agent, &content, process_exited) else { - pending_idle.clear(); - pending_working.clear(); - continue; - }; - let pty_activity = if agent.is_some() { - Some(agent_caused_pty_activity_active( + handle_skipped_detection_update( + state, + pty_activity, + &mut post_taint_working, + &mut pty_causality, pty_output_seq.load(Ordering::Relaxed), input_write_seq_for_task.load(Ordering::Relaxed), - &mut pty_causality, now, - )) - } else { - None - }; - let pty_signal = if let Some(signal) = pty_activity { - Some(crate::agent_detection_policy::PtySignal { - active: signal.active, - tainted: signal.tainted, - }) - } else { - None - }; - let detection = match crate::agent_detection_policy::apply_detection_policy( - crate::agent_detection_policy::DetectionPolicyInput { - agent, - screen_detection, - process_exited, - startup_grace_active: false, - pty_signal, - }, - ) { - crate::agent_detection_policy::DetectionPolicyDecision::Publish( - detection, - ) => detection, - crate::agent_detection_policy::DetectionPolicyDecision::Freeze => { - pending_idle.clear(); - pending_working.clear(); - continue; - } - }; - let new_state = crate::terminal::state::stabilize_agent_detection(detection); - let visible_blocker = - detection.visible_blocker && new_state == AgentState::Blocked; - let visible_working = - detection.visible_working && new_state == AgentState::Working; - - let previous_publish = DetectionPublishState { - state, - visible_blocker: last_visible_blocker, - visible_working: last_visible_working, - }; - let next_publish = DetectionPublishState { - state: new_state, - visible_blocker, - visible_working, - }; - let stable_refresh_due = stable_visible_signal_refresh_due( - previous_publish, - next_publish, - last_visible_signal_refresh, - now, - ); - - if pty_working_transition_is_vetoed( - agent, - previous_publish, - next_publish, - &content, - ) { + ); pending_idle.clear(); pending_working.clear(); continue; - } - - if pending_working.should_publish_held_working_before_exit( - previous_publish, - next_publish, - process_exited, - ) { - pending_idle.clear(); - state = AgentState::Working; - last_visible_blocker = false; - last_visible_working = false; - last_visible_signal_refresh = None; - publish_state_changed_event( - state_events.clone(), - pane_id, + }; + match decide_screen_detection_publish( + ScreenDetectionPublishInput { agent, - AgentState::Working, - false, - false, - false, + screen_detection, + current_state: state, + last_visible_blocker, + last_visible_working, + last_visible_signal_refresh, + process_exited, + agent_changed, + pty_activity, + content: &content, now, - ) - .await; - continue; - } - - if pending_working.should_hold_idle_to_working( - previous_publish, - next_publish, - agent_changed, - process_exited, - pty_activity, - now, - ) { - pending_idle.clear(); - continue; - } - - if pending_idle.should_hold_working_to_idle( - previous_publish, - next_publish, - agent_changed, - process_exited, - pty_activity, - now, - ) { - pending_working.clear(); - continue; - } - - if should_publish_detection_update( - previous_publish, - next_publish, - agent_changed, - process_exited, - stable_refresh_due, + }, + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, ) { - state = new_state; - last_visible_blocker = visible_blocker; - last_visible_working = visible_working; - if visible_blocker || visible_working { - last_visible_signal_refresh = Some(now); - } else { - last_visible_signal_refresh = None; - } - publish_state_changed_event( - state_events.clone(), - pane_id, - agent, - new_state, + DetectionPublishDecision::NoPublish => {} + DetectionPublishDecision::Publish { + state: new_state, visible_blocker, visible_working, - process_exited, - now, - ) - .await; - if process_exited { - foreground_shell_exit_reported = true; + process_exited: publish_process_exited, + } => { + apply_agent_detection_publish_update( + state_events.clone(), + pane_id, + agent, + AgentDetectionPublishUpdate { + state: new_state, + visible_blocker, + visible_working, + process_exited: publish_process_exited, + }, + now, + &mut state, + &mut last_visible_blocker, + &mut last_visible_working, + &mut last_visible_signal_refresh, + &mut foreground_shell_exit_reported, + ) + .await; } } } @@ -3320,561 +3013,6 @@ mod tests { } #[test] - fn codex_transcript_viewer_suppresses_prompt_idle_publish() { - let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; - - assert!(detection_update_for_publish(Some(Agent::Codex), content, false).is_none()); - } - - #[test] - fn codex_transcript_viewer_suppresses_process_exit_idle_publish() { - let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; - - assert!(detection_update_for_publish(Some(Agent::Codex), content, true).is_none()); - } - - #[test] - fn process_exit_without_transcript_still_reports_idle() { - let detection = - detection_update_for_publish(Some(Agent::Codex), "Codex finished\n› ", true) - .expect("process exit should publish idle outside transcript viewer"); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.skip_state_update); - } - - #[test] - fn stable_plain_idle_does_not_republish() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let refresh_due = stable_visible_signal_refresh_due( - previous, - previous, - Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), - now, - ); - - assert!(!should_publish_detection_update( - previous, - previous, - false, - false, - refresh_due - )); - } - - #[test] - fn stable_visible_working_does_not_republish() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: true, - }; - let refresh_due = stable_visible_signal_refresh_due( - previous, - previous, - Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), - now, - ); - - assert!(!should_publish_detection_update( - previous, - previous, - false, - false, - refresh_due - )); - } - - #[test] - fn stable_visible_blocker_republishes_for_hook_override_refresh() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Blocked, - visible_blocker: true, - visible_working: false, - }; - let refresh_due = stable_visible_signal_refresh_due( - previous, - previous, - Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), - now, - ); - - assert!(should_publish_detection_update( - previous, - previous, - false, - false, - refresh_due - )); - } - - #[test] - fn pending_idle_holds_working_to_plain_idle_until_confirmed() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let next = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingIdleConfirmation::default(); - - assert!(pending.should_hold_working_to_idle(previous, next, false, false, None, now)); - assert!(pending.should_hold_working_to_idle( - previous, - next, - false, - false, - None, - now + AGENT_PENDING_IDLE_RECHECK - )); - assert!(pending.should_hold_working_to_idle( - previous, - next, - false, - false, - None, - now + AGENT_PENDING_IDLE_RECHECK * 2 - )); - assert!(!pending.should_hold_working_to_idle( - previous, - next, - false, - false, - None, - now + AGENT_PENDING_IDLE_RECHECK * 3 - )); - } - - #[test] - fn pending_idle_cap_publishes_idle_when_still_quiet() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let next = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingIdleConfirmation::default(); - - assert!(pending.should_hold_working_to_idle(previous, next, false, false, None, now)); - assert!(!pending.should_hold_working_to_idle( - previous, - next, - false, - false, - None, - now + AGENT_PENDING_IDLE_CAP - )); - } - - #[test] - fn pending_idle_clears_when_work_resumes_or_blocker_appears() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let blocked = DetectionPublishState { - state: AgentState::Blocked, - visible_blocker: true, - visible_working: false, - }; - let mut pending = PendingIdleConfirmation::default(); - - assert!(pending.should_hold_working_to_idle(previous, idle, false, false, None, now)); - assert!(pending.active()); - assert!(!pending.should_hold_working_to_idle(previous, working, false, false, None, now)); - assert!(!pending.active()); - - assert!(pending.should_hold_working_to_idle(previous, idle, false, false, None, now)); - assert!(!pending.should_hold_working_to_idle(previous, blocked, false, false, None, now)); - assert!(!pending.active()); - } - - #[test] - fn pending_idle_does_not_extend_pty_quiet_lease() { - let now = std::time::Instant::now(); - let previous = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingIdleConfirmation::default(); - - assert!(!pending.should_hold_working_to_idle( - previous, - idle, - false, - false, - Some(pty_activity(false, false, 10)), - now - )); - assert!(!pending.active()); - } - - fn pty_activity(active: bool, fresh_output: bool, output_seq: u64) -> PtyActivitySignal { - PtyActivitySignal { - active, - tainted: false, - fresh_output, - output_seq, - } - } - - #[test] - fn pending_working_holds_single_twitch_then_clears_when_quiet() { - let now = std::time::Instant::now(); - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingWorkingConfirmation::default(); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 10)), - now - )); - assert!(pending.active()); - assert!(!pending.should_hold_idle_to_working( - idle, - idle, - false, - false, - Some(pty_activity(false, false, 10)), - now + AGENT_PTY_ACTIVITY_WINDOW - )); - assert!(!pending.active()); - } - - #[test] - fn pending_working_confirms_after_delayed_output_observation() { - let now = std::time::Instant::now(); - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingWorkingConfirmation::default(); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 10)), - now - )); - assert!(!pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 11)), - now + AGENT_PENDING_WORKING_CONFIRM_DELAY - )); - assert!(!pending.active()); - } - - #[test] - fn pending_working_counts_raw_sequence_jump_once() { - let now = std::time::Instant::now(); - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingWorkingConfirmation::default(); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 10)), - now - )); - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 14)), - now + AGENT_PENDING_WORKING_FAST_RECHECK - )); - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, false, 14)), - now + AGENT_PENDING_WORKING_FAST_RECHECK * 2 - )); - assert!(!pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 15)), - now + AGENT_PENDING_WORKING_CONFIRM_DELAY - )); - } - - #[test] - fn pending_working_cap_suppresses_unconfirmed_activity() { - let now = std::time::Instant::now(); - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingWorkingConfirmation::default(); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 10)), - now - )); - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 11)), - now + AGENT_PENDING_WORKING_FAST_RECHECK - )); - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, false, 11)), - now + AGENT_PENDING_WORKING_CAP - )); - assert!(!pending.active()); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 20)), - now - )); - assert!(!pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 21)), - now + AGENT_PENDING_WORKING_CONFIRM_DELAY - )); - assert!(!pending.active()); - } - - #[test] - fn pending_working_process_exit_publishes_held_working_first() { - let now = std::time::Instant::now(); - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - let mut pending = PendingWorkingConfirmation::default(); - - assert!(pending.should_hold_idle_to_working( - idle, - working, - false, - false, - Some(pty_activity(true, true, 10)), - now - )); - assert!(pending.should_publish_held_working_before_exit(idle, idle, true)); - assert!(!pending.active()); - } - - #[test] - fn pty_activity_distinguishes_fresh_output_from_active_hold() { - let now = std::time::Instant::now(); - let mut tracker = PtyCausalityTracker::default(); - baseline_pty_causality(&mut tracker, 1, 1); - - let fresh = agent_caused_pty_activity_active(2, 1, &mut tracker, now); - assert!(fresh.active); - assert!(fresh.fresh_output); - assert_eq!(fresh.output_seq, 2); - - let held = agent_caused_pty_activity_active( - 2, - 1, - &mut tracker, - now + AGENT_PENDING_WORKING_FAST_RECHECK, - ); - assert!(held.active); - assert!(!held.fresh_output); - assert_eq!(held.output_seq, 2); - } - - #[test] - fn pty_activity_lease_survives_sparse_heartbeat_jitter() { - let now = std::time::Instant::now(); - let mut tracker = PtyCausalityTracker::default(); - baseline_pty_causality(&mut tracker, 1, 1); - - let first = agent_caused_pty_activity_active(2, 1, &mut tracker, now); - assert!(first.active); - assert!(first.fresh_output); - - let held_before_next_tick = agent_caused_pty_activity_active( - 2, - 1, - &mut tracker, - now + std::time::Duration::from_millis(1700), - ); - assert!(held_before_next_tick.active); - assert!(!held_before_next_tick.fresh_output); - - let next_tick = agent_caused_pty_activity_active( - 3, - 1, - &mut tracker, - now + std::time::Duration::from_millis(1700), - ); - assert!(next_tick.active); - assert!(next_tick.fresh_output); - - let held_after_next_tick = agent_caused_pty_activity_active( - 3, - 1, - &mut tracker, - now + std::time::Duration::from_millis(3400), - ); - assert!(held_after_next_tick.active); - - let expired = agent_caused_pty_activity_active( - 3, - 1, - &mut tracker, - now + std::time::Duration::from_millis(3501), - ); - assert!(!expired.active); - } - - #[test] - fn pty_output_activity_tracks_raw_nonempty_reads() { - let seq = AtomicU64::new(0); - - observe_pty_output_activity(b"", &seq); - assert_eq!(seq.load(Ordering::Relaxed), 0); - - observe_pty_output_activity(b"\x1b[?2026h", &seq); - assert_eq!(seq.load(Ordering::Relaxed), 1); - - observe_pty_output_activity(b"body bytes", &seq); - assert_eq!(seq.load(Ordering::Relaxed), 2); - } - - #[test] - fn claude_recap_veto_only_applies_to_idle_to_working() { - let screen = - "※ recap: Done. (disable recaps in /config)\n\n─────────────\n❯ \n─────────────"; - let idle = DetectionPublishState { - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - }; - let working = DetectionPublishState { - state: AgentState::Working, - visible_blocker: false, - visible_working: false, - }; - - assert!(pty_working_transition_is_vetoed( - Some(Agent::Claude), - idle, - working, - screen - )); - assert!(!pty_working_transition_is_vetoed( - Some(Agent::Claude), - working, - idle, - screen - )); - assert!(!pty_working_transition_is_vetoed( - Some(Agent::Codex), - idle, - working, - screen - )); - } - - #[test] fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() { assert!(!should_clear_agent_for_foreground_shell( Some(Agent::Claude), @@ -4251,81 +3389,6 @@ mod tests { assert_eq!(presence.current_agent(), None); } - #[test] - fn pty_activity_outside_taint_reports_active_until_hold_expires() { - let now = std::time::Instant::now(); - let mut tracker = PtyCausalityTracker::default(); - baseline_pty_causality(&mut tracker, 1, 1); - - let active = agent_caused_pty_activity_active(2, 1, &mut tracker, now); - assert!(active.active); - assert!(!active.tainted); - - let held = agent_caused_pty_activity_active( - 2, - 1, - &mut tracker, - now + AGENT_PTY_ACTIVITY_WINDOW - std::time::Duration::from_millis(1), - ); - assert!(held.active); - assert!(!held.tainted); - - let expired = agent_caused_pty_activity_active( - 2, - 1, - &mut tracker, - now + AGENT_PTY_ACTIVITY_WINDOW + std::time::Duration::from_millis(1), - ); - assert!(!expired.active); - assert!(!expired.tainted); - } - - #[test] - fn input_taint_discards_pty_activity_until_fresh_post_taint_output() { - let now = std::time::Instant::now(); - let mut tracker = PtyCausalityTracker::default(); - baseline_pty_causality(&mut tracker, 1, 1); - - let tainted = agent_caused_pty_activity_active(2, 2, &mut tracker, now); - assert!(!tainted.active); - assert!(tainted.tainted); - - let after_taint = agent_caused_pty_activity_active( - 2, - 2, - &mut tracker, - now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(1), - ); - assert!(!after_taint.active); - assert!(!after_taint.tainted); - - let fresh_output = agent_caused_pty_activity_active( - 3, - 2, - &mut tracker, - now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(2), - ); - assert!(fresh_output.active); - assert!(!fresh_output.tainted); - } - - #[test] - fn startup_grace_rebaseline_discards_accumulated_pty_activity() { - let now = std::time::Instant::now(); - let mut tracker = PtyCausalityTracker::default(); - baseline_pty_causality(&mut tracker, 1, 1); - - let startup_render = agent_caused_pty_activity_active(2, 1, &mut tracker, now); - assert!(startup_render.active); - - baseline_pty_causality(&mut tracker, 2, 1); - let after_grace = - agent_caused_pty_activity_active(2, 1, &mut tracker, now + AGENT_STARTUP_GRACE_WINDOW); - - assert!(!after_grace.active); - assert!(!after_grace.tainted); - } - #[tokio::test] async fn state_changed_event_waits_for_queue_space_instead_of_dropping() { let (tx, mut rx) = mpsc::channel(1); diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs new file mode 100644 index 00000000..1a3e795a --- /dev/null +++ b/src/pane/agent_detection.rs @@ -0,0 +1,1886 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::detect::{Agent, AgentDetection, AgentState}; + +pub(super) const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration = + std::time::Duration::from_millis(1800); +pub(super) const AGENT_INPUT_TAINT_WINDOW: std::time::Duration = + std::time::Duration::from_millis(1200); +pub(super) const AGENT_POST_TAINT_WORKING_LEASE: std::time::Duration = AGENT_PTY_ACTIVITY_WINDOW; +pub(super) const AGENT_PENDING_IDLE_RECHECK: std::time::Duration = + std::time::Duration::from_millis(100); +const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3; +pub(super) const AGENT_PENDING_IDLE_CAP: std::time::Duration = + std::time::Duration::from_millis(700); +pub(super) const AGENT_PENDING_WORKING_FAST_RECHECK: std::time::Duration = + std::time::Duration::from_millis(100); +const AGENT_PENDING_WORKING_SLOW_RECHECK: std::time::Duration = + std::time::Duration::from_millis(250); +const AGENT_PENDING_WORKING_FAST_WINDOW: std::time::Duration = + std::time::Duration::from_millis(500); +pub(super) const AGENT_PENDING_WORKING_CAP: std::time::Duration = std::time::Duration::from_secs(2); +pub(super) const AGENT_PENDING_WORKING_CONFIRM_DELAY: std::time::Duration = + std::time::Duration::from_millis(250); +pub(super) const STABLE_VISIBLE_SIGNAL_REFRESH: std::time::Duration = + std::time::Duration::from_millis(800); +pub(super) const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = + std::time::Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DetectionPublishState { + pub(super) state: AgentState, + pub(super) visible_blocker: bool, + pub(super) visible_working: bool, +} + +#[derive(Debug, Default)] +pub(super) struct PendingIdleConfirmation { + started_at: Option<std::time::Instant>, + confirmations: u8, +} + +impl PendingIdleConfirmation { + pub(super) fn active(&self) -> bool { + self.started_at.is_some() + } + + pub(super) fn clear(&mut self) { + self.started_at = None; + self.confirmations = 0; + } + + pub(super) fn should_hold_working_to_idle( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, + pty_signal: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> bool { + let is_working_to_plain_idle = previous.state == AgentState::Working + && next.state == AgentState::Idle + && !next.visible_blocker + && !agent_changed + && !process_exited; + + if !is_working_to_plain_idle { + self.clear(); + return false; + } + + if pty_signal.is_some_and(|signal| !signal.active && !signal.tainted) { + self.clear(); + return false; + } + + let Some(started_at) = self.started_at else { + self.started_at = Some(now); + self.confirmations = 0; + return true; + }; + + if now.duration_since(started_at) >= AGENT_PENDING_IDLE_CAP { + self.clear(); + return false; + } + + self.confirmations = self.confirmations.saturating_add(1); + if self.confirmations >= AGENT_PENDING_IDLE_CONFIRMATIONS { + self.clear(); + return false; + } + + true + } +} + +#[derive(Debug, Default)] +pub(super) struct PendingWorkingConfirmation { + started_at: Option<std::time::Instant>, + last_observed_output_seq: u64, +} + +impl PendingWorkingConfirmation { + pub(super) fn active(&self) -> bool { + self.started_at.is_some() + } + + pub(super) fn clear(&mut self) { + self.started_at = None; + self.last_observed_output_seq = 0; + } + + pub(super) fn recheck_delay(&self, now: std::time::Instant) -> std::time::Duration { + let Some(started_at) = self.started_at else { + return AGENT_PENDING_WORKING_FAST_RECHECK; + }; + if now.duration_since(started_at) < AGENT_PENDING_WORKING_FAST_WINDOW { + AGENT_PENDING_WORKING_FAST_RECHECK + } else { + AGENT_PENDING_WORKING_SLOW_RECHECK + } + } + + pub(super) fn should_hold_idle_to_working( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, + pty_signal: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> bool { + let is_idle_to_working = previous.state == AgentState::Idle + && next.state == AgentState::Working + && !next.visible_blocker + && !agent_changed + && !process_exited; + + if !is_idle_to_working { + self.clear(); + return false; + } + + let Some(pty_signal) = pty_signal else { + self.clear(); + return false; + }; + if !pty_signal.active { + self.clear(); + return false; + } + + let Some(started_at) = self.started_at else { + self.started_at = Some(now); + self.last_observed_output_seq = pty_signal.output_seq; + return true; + }; + + if pty_signal.fresh_output && pty_signal.output_seq != self.last_observed_output_seq { + self.last_observed_output_seq = pty_signal.output_seq; + if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CONFIRM_DELAY { + self.clear(); + return false; + } + } + + if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CAP { + self.clear(); + return true; + } + + true + } + + pub(super) fn should_publish_held_working_before_exit( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + process_exited: bool, + ) -> bool { + if self.started_at.is_none() + || !process_exited + || previous.state != AgentState::Idle + || next.state != AgentState::Idle + { + return false; + } + + self.clear(); + true + } +} + +#[derive(Debug, Default)] +pub(super) struct PostTaintWorkingLease { + until: Option<std::time::Instant>, +} + +impl PostTaintWorkingLease { + pub(super) fn active(&self) -> bool { + self.until.is_some() + } + + pub(super) fn start(&mut self, now: std::time::Instant) { + self.until = Some(now + AGENT_POST_TAINT_WORKING_LEASE); + } + + pub(super) fn clear(&mut self) { + self.until = None; + } + + pub(super) fn should_hold_working_to_idle( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, + pty_signal: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> bool { + let is_working_to_plain_idle = previous.state == AgentState::Working + && next.state == AgentState::Idle + && !next.visible_blocker + && !agent_changed + && !process_exited; + + if !is_working_to_plain_idle { + self.clear(); + return false; + } + + let Some(pty_signal) = pty_signal else { + self.clear(); + return false; + }; + + if pty_signal.active || pty_signal.tainted { + self.clear(); + return false; + } + + if pty_signal.taint_just_ended { + self.start(now); + return true; + } + + let Some(until) = self.until else { + return false; + }; + + if now < until { + return true; + } + + self.clear(); + false + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct IdleScreenScanSkipInput { + pub(super) state: AgentState, + pub(super) agent: Option<Agent>, + pub(super) pending_idle_active: bool, + pub(super) pending_working_active: bool, + pub(super) post_taint_working_active: bool, + pub(super) agent_changed: bool, + pub(super) process_exited: bool, + pub(super) pty_signal: Option<PtyActivitySignal>, + pub(super) last_screen_scan_pty_output_seq: Option<u64>, +} + +pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bool { + if input.state != AgentState::Idle + || input.agent.is_none() + || input.pending_idle_active + || input.pending_working_active + || input.post_taint_working_active + || input.agent_changed + || input.process_exited + { + return false; + } + + let Some(pty_signal) = input.pty_signal else { + return false; + }; + + !pty_signal.active + && !pty_signal.tainted + && !pty_signal.taint_just_ended + && input.last_screen_scan_pty_output_seq == Some(pty_signal.output_seq) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DetectionScreenReadDecision { + Read, + Skip, + Publish { + state: AgentState, + visible_blocker: bool, + visible_working: bool, + process_exited: bool, + }, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct DetectionScreenReadInput { + pub(super) state: AgentState, + pub(super) agent: Option<Agent>, + pub(super) pending_idle_active: bool, + pub(super) pending_working_active: bool, + pub(super) post_taint_working_active: bool, + pub(super) agent_changed: bool, + pub(super) process_exited: bool, + pub(super) pty_activity: Option<PtyActivitySignal>, + pub(super) last_screen_scan_pty_output_seq: Option<u64>, +} + +pub(super) fn decide_detection_screen_read( + input: DetectionScreenReadInput, +) -> DetectionScreenReadDecision { + if !input.agent_changed + && !input.process_exited + && !input.pending_idle_active + && !input.pending_working_active + && !input.post_taint_working_active + && input.agent.is_some() + && input + .pty_activity + .is_some_and(|signal| signal.active && !signal.tainted) + { + return match input.state { + AgentState::Working => DetectionScreenReadDecision::Skip, + AgentState::Blocked => DetectionScreenReadDecision::Publish { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + process_exited: false, + }, + AgentState::Idle | AgentState::Unknown => DetectionScreenReadDecision::Read, + }; + } + + if should_skip_idle_screen_scan(IdleScreenScanSkipInput { + state: input.state, + agent: input.agent, + pending_idle_active: input.pending_idle_active, + pending_working_active: input.pending_working_active, + post_taint_working_active: input.post_taint_working_active, + agent_changed: input.agent_changed, + process_exited: input.process_exited, + pty_signal: input.pty_activity, + last_screen_scan_pty_output_seq: input.last_screen_scan_pty_output_seq, + }) { + DetectionScreenReadDecision::Skip + } else { + DetectionScreenReadDecision::Read + } +} + +pub(super) fn pty_working_transition_is_vetoed( + agent: Option<Agent>, + previous: DetectionPublishState, + next: DetectionPublishState, + content: &str, +) -> bool { + previous.state == AgentState::Idle + && next.state == AgentState::Working + && !next.visible_blocker + && crate::detect::agent_activity_veto(agent, content).is_some() +} + +pub(super) fn should_publish_detection_update( + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, + stable_visible_signal_refresh_due: bool, +) -> bool { + next.state != previous.state + || next.visible_blocker != previous.visible_blocker + || next.visible_working != previous.visible_working + || agent_changed + || process_exited + || (stable_visible_signal_refresh_due && next.visible_blocker && previous.visible_blocker) +} + +pub(super) fn stable_visible_signal_refresh_due( + previous: DetectionPublishState, + next: DetectionPublishState, + last_refresh: Option<std::time::Instant>, + now: std::time::Instant, +) -> bool { + let stable_visible_signal = next.visible_blocker && previous.visible_blocker; + + stable_visible_signal + && last_refresh.is_none_or(|last_refresh| { + now.duration_since(last_refresh) >= STABLE_VISIBLE_SIGNAL_REFRESH + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DetectionTransitionDecision { + NoPublish, + PublishHeldWorkingBeforeExit, + PublishNext, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct DetectionTransitionInput<'a> { + pub(super) agent: Option<Agent>, + pub(super) previous_publish: DetectionPublishState, + pub(super) next_publish: DetectionPublishState, + pub(super) agent_changed: bool, + pub(super) process_exited: bool, + pub(super) pty_activity: Option<PtyActivitySignal>, + pub(super) stable_refresh_due: bool, + pub(super) content: &'a str, + pub(super) now: std::time::Instant, +} + +pub(super) fn decide_detection_transition( + input: DetectionTransitionInput<'_>, + pending_idle: &mut PendingIdleConfirmation, + pending_working: &mut PendingWorkingConfirmation, + post_taint_working: &mut PostTaintWorkingLease, +) -> DetectionTransitionDecision { + if pty_working_transition_is_vetoed( + input.agent, + input.previous_publish, + input.next_publish, + input.content, + ) { + pending_idle.clear(); + pending_working.clear(); + post_taint_working.clear(); + return DetectionTransitionDecision::NoPublish; + } + + if pending_working.should_publish_held_working_before_exit( + input.previous_publish, + input.next_publish, + input.process_exited, + ) { + pending_idle.clear(); + post_taint_working.clear(); + return DetectionTransitionDecision::PublishHeldWorkingBeforeExit; + } + + if pending_working.should_hold_idle_to_working( + input.previous_publish, + input.next_publish, + input.agent_changed, + input.process_exited, + input.pty_activity, + input.now, + ) { + pending_idle.clear(); + post_taint_working.clear(); + return DetectionTransitionDecision::NoPublish; + } + + if post_taint_working.should_hold_working_to_idle( + input.previous_publish, + input.next_publish, + input.agent_changed, + input.process_exited, + input.pty_activity, + input.now, + ) { + pending_idle.clear(); + pending_working.clear(); + return DetectionTransitionDecision::NoPublish; + } + + if pending_idle.should_hold_working_to_idle( + input.previous_publish, + input.next_publish, + input.agent_changed, + input.process_exited, + input.pty_activity, + input.now, + ) { + pending_working.clear(); + post_taint_working.clear(); + return DetectionTransitionDecision::NoPublish; + } + + if should_publish_detection_update( + input.previous_publish, + input.next_publish, + input.agent_changed, + input.process_exited, + input.stable_refresh_due, + ) { + return DetectionTransitionDecision::PublishNext; + } + + DetectionTransitionDecision::NoPublish +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DetectionPublishDecision { + NoPublish, + Publish { + state: AgentState, + visible_blocker: bool, + visible_working: bool, + process_exited: bool, + }, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ScreenDetectionPublishInput<'a> { + pub(super) agent: Option<Agent>, + pub(super) current_state: AgentState, + pub(super) last_visible_blocker: bool, + pub(super) last_visible_working: bool, + pub(super) last_visible_signal_refresh: Option<std::time::Instant>, + pub(super) screen_detection: AgentDetection, + pub(super) process_exited: bool, + pub(super) agent_changed: bool, + pub(super) pty_activity: Option<PtyActivitySignal>, + pub(super) content: &'a str, + pub(super) now: std::time::Instant, +} + +pub(super) fn decide_screen_detection_publish( + input: ScreenDetectionPublishInput<'_>, + pending_idle: &mut PendingIdleConfirmation, + pending_working: &mut PendingWorkingConfirmation, + post_taint_working: &mut PostTaintWorkingLease, +) -> DetectionPublishDecision { + let pty_signal = input + .pty_activity + .map(|signal| crate::agent_detection_policy::PtySignal { + active: signal.active, + tainted: signal.tainted, + }); + let detection = match crate::agent_detection_policy::apply_detection_policy( + crate::agent_detection_policy::DetectionPolicyInput { + agent: input.agent, + screen_detection: input.screen_detection, + process_exited: input.process_exited, + startup_grace_active: false, + pty_signal, + }, + ) { + crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => detection, + crate::agent_detection_policy::DetectionPolicyDecision::Freeze => { + pending_idle.clear(); + pending_working.clear(); + post_taint_working.clear(); + return DetectionPublishDecision::NoPublish; + } + }; + let new_state = crate::terminal::state::stabilize_agent_detection(detection); + let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; + let visible_working = detection.visible_working && new_state == AgentState::Working; + + let previous_publish = DetectionPublishState { + state: input.current_state, + visible_blocker: input.last_visible_blocker, + visible_working: input.last_visible_working, + }; + let next_publish = DetectionPublishState { + state: new_state, + visible_blocker, + visible_working, + }; + let stable_refresh_due = stable_visible_signal_refresh_due( + previous_publish, + next_publish, + input.last_visible_signal_refresh, + input.now, + ); + + match decide_detection_transition( + DetectionTransitionInput { + agent: input.agent, + previous_publish, + next_publish, + agent_changed: input.agent_changed, + process_exited: input.process_exited, + pty_activity: input.pty_activity, + stable_refresh_due, + content: input.content, + now: input.now, + }, + pending_idle, + pending_working, + post_taint_working, + ) { + DetectionTransitionDecision::NoPublish => DetectionPublishDecision::NoPublish, + DetectionTransitionDecision::PublishHeldWorkingBeforeExit => { + DetectionPublishDecision::Publish { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + process_exited: false, + } + } + DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish { + state: new_state, + visible_blocker, + visible_working, + process_exited: input.process_exited, + }, + } +} + +pub(super) fn detection_update_for_publish( + agent: Option<Agent>, + content: &str, + process_exited: bool, +) -> Option<crate::detect::AgentDetection> { + if crate::detect::should_skip_state_update(agent, content) { + return None; + } + + if process_exited { + return Some(crate::detect::AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }); + } + + let detection = crate::detect::detect_agent(agent, content); + (!detection.skip_state_update).then_some(detection) +} + +#[derive(Debug, Default)] +pub(super) struct PtyCausalityTracker { + last_pty_output_seq: u64, + last_input_seq: u64, + input_tainted_until: Option<std::time::Instant>, + last_agent_pty_at: Option<std::time::Instant>, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct PtyActivitySignal { + pub(super) active: bool, + pub(super) tainted: bool, + pub(super) taint_just_ended: bool, + pub(super) fresh_output: bool, + pub(super) output_seq: u64, +} + +pub(super) fn baseline_pty_causality( + tracker: &mut PtyCausalityTracker, + pty_output_seq: u64, + input_seq: u64, +) { + tracker.last_pty_output_seq = pty_output_seq; + tracker.last_input_seq = input_seq; + tracker.input_tainted_until = None; + tracker.last_agent_pty_at = None; +} + +pub(super) fn agent_caused_pty_activity_active( + pty_output_seq: u64, + input_seq: u64, + tracker: &mut PtyCausalityTracker, + now: std::time::Instant, +) -> PtyActivitySignal { + if input_seq != tracker.last_input_seq { + tracker.last_input_seq = input_seq; + tracker.input_tainted_until = Some(now + AGENT_INPUT_TAINT_WINDOW); + tracker.last_agent_pty_at = None; + } + + let mut taint_just_ended = false; + let tainted = match tracker.input_tainted_until { + Some(until) if now < until => true, + Some(_) => { + tracker.input_tainted_until = None; + taint_just_ended = true; + false + } + None => false, + }; + + let mut fresh_output = false; + if pty_output_seq != tracker.last_pty_output_seq { + tracker.last_pty_output_seq = pty_output_seq; + if !tainted { + tracker.last_agent_pty_at = Some(now); + fresh_output = true; + } + } + + if tainted { + return PtyActivitySignal { + active: false, + tainted: true, + taint_just_ended: false, + fresh_output: false, + output_seq: tracker.last_pty_output_seq, + }; + } + + let active = tracker + .last_agent_pty_at + .is_some_and(|last| now.duration_since(last) < AGENT_PTY_ACTIVITY_WINDOW); + PtyActivitySignal { + active, + tainted: false, + taint_just_ended, + fresh_output, + output_seq: tracker.last_pty_output_seq, + } +} + +pub(super) fn observe_pty_output_activity(bytes: &[u8], pty_output_seq: &AtomicU64) { + if !bytes.is_empty() { + pty_output_seq.fetch_add(1, Ordering::Relaxed); + } +} + +fn consume_skipped_pty_causality( + tracker: &mut PtyCausalityTracker, + pty_output_seq: u64, + input_seq: u64, +) { + baseline_pty_causality(tracker, pty_output_seq, input_seq); +} + +pub(super) fn handle_skipped_detection_update( + state: AgentState, + pty_signal: Option<PtyActivitySignal>, + post_taint_working: &mut PostTaintWorkingLease, + tracker: &mut PtyCausalityTracker, + pty_output_seq: u64, + input_seq: u64, + now: std::time::Instant, +) { + if state == AgentState::Working { + if pty_signal.is_some_and(|signal| signal.taint_just_ended) { + post_taint_working.start(now); + } + return; + } + + post_taint_working.clear(); + consume_skipped_pty_causality(tracker, pty_output_seq, input_seq); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn publish_state(state: AgentState) -> DetectionPublishState { + DetectionPublishState { + state, + visible_blocker: false, + visible_working: false, + } + } + + fn pty_activity(active: bool, fresh_output: bool, output_seq: u64) -> PtyActivitySignal { + PtyActivitySignal { + active, + tainted: false, + taint_just_ended: false, + fresh_output, + output_seq, + } + } + + fn pty_activity_after_taint(output_seq: u64) -> PtyActivitySignal { + PtyActivitySignal { + active: false, + tainted: false, + taint_just_ended: true, + fresh_output: false, + output_seq, + } + } + + fn tainted_pty_activity(output_seq: u64) -> PtyActivitySignal { + PtyActivitySignal { + active: false, + tainted: true, + taint_just_ended: false, + fresh_output: false, + output_seq, + } + } + + fn transition_input( + previous_publish: DetectionPublishState, + next_publish: DetectionPublishState, + pty_activity: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> DetectionTransitionInput<'static> { + DetectionTransitionInput { + agent: Some(Agent::Codex), + previous_publish, + next_publish, + agent_changed: false, + process_exited: false, + pty_activity, + stable_refresh_due: false, + content: "", + now, + } + } + + fn screen_detection(state: AgentState) -> AgentDetection { + AgentDetection { + state, + skip_state_update: false, + visible_blocker: false, + visible_working: state == AgentState::Working, + } + } + + fn screen_publish_input( + current_state: AgentState, + screen_detection: AgentDetection, + pty_activity: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> ScreenDetectionPublishInput<'static> { + ScreenDetectionPublishInput { + agent: Some(Agent::Codex), + current_state, + last_visible_blocker: false, + last_visible_working: false, + last_visible_signal_refresh: None, + screen_detection, + process_exited: false, + agent_changed: false, + pty_activity, + content: "", + now, + } + } + + fn screen_read_input( + state: AgentState, + pty_activity: PtyActivitySignal, + ) -> DetectionScreenReadInput { + DetectionScreenReadInput { + state, + agent: Some(Agent::Codex), + pending_idle_active: false, + pending_working_active: false, + post_taint_working_active: false, + agent_changed: false, + process_exited: false, + pty_activity: Some(pty_activity), + last_screen_scan_pty_output_seq: Some(10), + } + } + + #[test] + fn screen_read_decision_skips_only_stable_idle_quiet_output() { + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Idle, + pty_activity(false, false, 10), + )), + DetectionScreenReadDecision::Skip + ); + + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Idle, + pty_activity(false, false, 11), + )), + DetectionScreenReadDecision::Read + ); + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Idle, + pty_activity(true, true, 10), + )), + DetectionScreenReadDecision::Read + ); + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Working, + pty_activity(false, false, 10), + )), + DetectionScreenReadDecision::Read + ); + } + + #[test] + fn screen_read_decision_handles_active_pty_without_screen_for_non_idle_states() { + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Working, + pty_activity(true, true, 11), + )), + DetectionScreenReadDecision::Skip + ); + + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Blocked, + pty_activity(true, true, 11), + )), + DetectionScreenReadDecision::Publish { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + process_exited: false, + } + ); + + assert_eq!( + decide_detection_screen_read(screen_read_input( + AgentState::Idle, + pty_activity(true, true, 11), + )), + DetectionScreenReadDecision::Read + ); + } + + #[test] + fn screen_read_decision_reads_during_pending_transitions() { + let mut input = screen_read_input(AgentState::Idle, pty_activity(false, false, 10)); + input.pending_working_active = true; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + + let mut input = screen_read_input(AgentState::Idle, pty_activity(false, false, 10)); + input.pending_idle_active = true; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + + let mut input = screen_read_input(AgentState::Idle, pty_activity(false, false, 10)); + input.post_taint_working_active = true; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + } + + #[test] + fn codex_transcript_viewer_suppresses_prompt_idle_publish() { + let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; + + assert!(detection_update_for_publish(Some(Agent::Codex), content, false).is_none()); + } + + #[test] + fn codex_transcript_viewer_suppresses_process_exit_idle_publish() { + let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; + + assert!(detection_update_for_publish(Some(Agent::Codex), content, true).is_none()); + } + + #[test] + fn process_exit_without_transcript_still_reports_idle() { + let detection = + detection_update_for_publish(Some(Agent::Codex), "Codex finished\n› ", true) + .expect("process exit should publish idle outside transcript viewer"); + + assert_eq!(detection.state, AgentState::Idle); + assert!(!detection.skip_state_update); + } + + #[test] + fn stable_plain_idle_does_not_republish() { + let now = std::time::Instant::now(); + let previous = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); + + assert!(!should_publish_detection_update( + previous, + previous, + false, + false, + refresh_due + )); + } + + #[test] + fn stable_visible_working_does_not_republish() { + let now = std::time::Instant::now(); + let previous = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: true, + }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); + + assert!(!should_publish_detection_update( + previous, + previous, + false, + false, + refresh_due + )); + } + + #[test] + fn stable_visible_blocker_republishes_for_hook_override_refresh() { + let now = std::time::Instant::now(); + let previous = DetectionPublishState { + state: AgentState::Blocked, + visible_blocker: true, + visible_working: false, + }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); + + assert!(should_publish_detection_update( + previous, + previous, + false, + false, + refresh_due + )); + } + + #[test] + fn pending_idle_holds_working_to_plain_idle_until_confirmed() { + let now = std::time::Instant::now(); + let previous = publish_state(AgentState::Working); + let next = publish_state(AgentState::Idle); + let mut pending = PendingIdleConfirmation::default(); + + assert!(pending.should_hold_working_to_idle(previous, next, false, false, None, now)); + assert!(pending.should_hold_working_to_idle( + previous, + next, + false, + false, + None, + now + AGENT_PENDING_IDLE_RECHECK + )); + assert!(pending.should_hold_working_to_idle( + previous, + next, + false, + false, + None, + now + AGENT_PENDING_IDLE_RECHECK * 2 + )); + assert!(!pending.should_hold_working_to_idle( + previous, + next, + false, + false, + None, + now + AGENT_PENDING_IDLE_RECHECK * 3 + )); + } + + #[test] + fn pending_idle_cap_publishes_idle_when_still_quiet() { + let now = std::time::Instant::now(); + let previous = publish_state(AgentState::Working); + let next = publish_state(AgentState::Idle); + let mut pending = PendingIdleConfirmation::default(); + + assert!(pending.should_hold_working_to_idle(previous, next, false, false, None, now)); + assert!(!pending.should_hold_working_to_idle( + previous, + next, + false, + false, + None, + now + AGENT_PENDING_IDLE_CAP + )); + } + + #[test] + fn pending_idle_clears_when_work_resumes_or_blocker_appears() { + let now = std::time::Instant::now(); + let previous = publish_state(AgentState::Working); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut blocked = publish_state(AgentState::Blocked); + blocked.visible_blocker = true; + let mut pending = PendingIdleConfirmation::default(); + + assert!(pending.should_hold_working_to_idle(previous, idle, false, false, None, now)); + assert!(pending.active()); + assert!(!pending.should_hold_working_to_idle(previous, working, false, false, None, now)); + assert!(!pending.active()); + + assert!(pending.should_hold_working_to_idle(previous, idle, false, false, None, now)); + assert!(!pending.should_hold_working_to_idle(previous, blocked, false, false, None, now)); + assert!(!pending.active()); + } + + #[test] + fn pending_idle_does_not_extend_pty_quiet_lease() { + let now = std::time::Instant::now(); + let previous = publish_state(AgentState::Working); + let idle = publish_state(AgentState::Idle); + let mut pending = PendingIdleConfirmation::default(); + + assert!(!pending.should_hold_working_to_idle( + previous, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + )); + assert!(!pending.active()); + } + + #[test] + fn post_taint_lease_holds_existing_working_before_idle_fallback() { + let now = std::time::Instant::now(); + let previous = publish_state(AgentState::Working); + let idle = publish_state(AgentState::Idle); + let mut lease = PostTaintWorkingLease::default(); + + assert!(lease.should_hold_working_to_idle( + previous, + idle, + false, + false, + Some(pty_activity_after_taint(10)), + now + )); + assert!(lease.should_hold_working_to_idle( + previous, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + AGENT_POST_TAINT_WORKING_LEASE - std::time::Duration::from_millis(1) + )); + assert!(!lease.should_hold_working_to_idle( + previous, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + AGENT_POST_TAINT_WORKING_LEASE + std::time::Duration::from_millis(1) + )); + } + + #[test] + fn post_taint_lease_does_not_create_idle_to_working() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut lease = PostTaintWorkingLease::default(); + + assert!(!lease.should_hold_working_to_idle( + idle, + working, + false, + false, + Some(pty_activity_after_taint(10)), + now + )); + } + + fn idle_scan_skip_input( + state: AgentState, + pty_signal: PtyActivitySignal, + ) -> IdleScreenScanSkipInput { + IdleScreenScanSkipInput { + state, + agent: Some(Agent::Codex), + pending_idle_active: false, + pending_working_active: false, + post_taint_working_active: false, + agent_changed: false, + process_exited: false, + pty_signal: Some(pty_signal), + last_screen_scan_pty_output_seq: Some(10), + } + } + + #[test] + fn idle_screen_scan_skip_accepts_only_idle_same_quiet_output() { + assert!(should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Idle, + pty_activity(false, false, 10) + ))); + + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Idle, + pty_activity(false, false, 11) + ))); + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Idle, + pty_activity(true, true, 10) + ))); + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Idle, + tainted_pty_activity(10) + ))); + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Working, + pty_activity(false, false, 10) + ))); + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Blocked, + pty_activity(false, false, 10) + ))); + assert!(!should_skip_idle_screen_scan(idle_scan_skip_input( + AgentState::Idle, + pty_activity_after_taint(10) + ))); + } + + #[test] + fn idle_screen_scan_skip_respects_transitions_and_missing_agent() { + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.pending_working_active = true; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.pending_idle_active = true; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.post_taint_working_active = true; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.agent_changed = true; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.process_exited = true; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.agent = None; + assert!(!should_skip_idle_screen_scan(input)); + + let mut input = idle_scan_skip_input(AgentState::Idle, pty_activity(false, false, 10)); + input.pty_signal = None; + assert!(!should_skip_idle_screen_scan(input)); + } + + #[test] + fn pending_working_holds_single_twitch_then_clears_when_quiet() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.active()); + assert!(!pending.should_hold_idle_to_working( + idle, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + AGENT_PTY_ACTIVITY_WINDOW + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_confirms_after_delayed_output_observation() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 11)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_counts_raw_sequence_jump_once() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 14)), + now + AGENT_PENDING_WORKING_FAST_RECHECK + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, false, 14)), + now + AGENT_PENDING_WORKING_FAST_RECHECK * 2 + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 15)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + } + + #[test] + fn pending_working_cap_suppresses_unconfirmed_activity() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 11)), + now + AGENT_PENDING_WORKING_FAST_RECHECK + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, false, 11)), + now + AGENT_PENDING_WORKING_CAP + )); + assert!(!pending.active()); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 20)), + now + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 21)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_process_exit_publishes_held_working_first() { + let now = std::time::Instant::now(); + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_publish_held_working_before_exit(idle, idle, true)); + assert!(!pending.active()); + } + + #[test] + fn pty_activity_distinguishes_fresh_output_from_active_hold() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let fresh = agent_caused_pty_activity_active(2, 1, &mut tracker, now); + assert!(fresh.active); + assert!(fresh.fresh_output); + assert_eq!(fresh.output_seq, 2); + + let held = agent_caused_pty_activity_active( + 2, + 1, + &mut tracker, + now + AGENT_PENDING_WORKING_FAST_RECHECK, + ); + assert!(held.active); + assert!(!held.fresh_output); + assert_eq!(held.output_seq, 2); + } + + #[test] + fn pty_activity_lease_survives_sparse_heartbeat_jitter() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let first = agent_caused_pty_activity_active(2, 1, &mut tracker, now); + assert!(first.active); + assert!(first.fresh_output); + + let held_before_next_tick = agent_caused_pty_activity_active( + 2, + 1, + &mut tracker, + now + std::time::Duration::from_millis(1700), + ); + assert!(held_before_next_tick.active); + assert!(!held_before_next_tick.fresh_output); + + let next_tick = agent_caused_pty_activity_active( + 3, + 1, + &mut tracker, + now + std::time::Duration::from_millis(1700), + ); + assert!(next_tick.active); + assert!(next_tick.fresh_output); + + let held_after_next_tick = agent_caused_pty_activity_active( + 3, + 1, + &mut tracker, + now + std::time::Duration::from_millis(3400), + ); + assert!(held_after_next_tick.active); + + let expired = agent_caused_pty_activity_active( + 3, + 1, + &mut tracker, + now + std::time::Duration::from_millis(3501), + ); + assert!(!expired.active); + } + + #[test] + fn pty_output_activity_tracks_raw_nonempty_reads() { + let seq = AtomicU64::new(0); + + observe_pty_output_activity(b"", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 0); + + observe_pty_output_activity(b"\x1b[?2026h", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 1); + + observe_pty_output_activity(b"body bytes", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 2); + } + + #[test] + fn claude_recap_veto_only_applies_to_idle_to_working() { + let screen = + "※ recap: Done. (disable recaps in /config)\n\n─────────────\n❯ \n─────────────"; + let idle = publish_state(AgentState::Idle); + let working = publish_state(AgentState::Working); + + assert!(pty_working_transition_is_vetoed( + Some(Agent::Claude), + idle, + working, + screen + )); + assert!(!pty_working_transition_is_vetoed( + Some(Agent::Claude), + working, + idle, + screen + )); + assert!(!pty_working_transition_is_vetoed( + Some(Agent::Codex), + idle, + working, + screen + )); + } + + #[test] + fn pty_activity_outside_taint_reports_active_until_hold_expires() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let active = agent_caused_pty_activity_active(2, 1, &mut tracker, now); + assert!(active.active); + assert!(!active.tainted); + + let held = agent_caused_pty_activity_active( + 2, + 1, + &mut tracker, + now + AGENT_PTY_ACTIVITY_WINDOW - std::time::Duration::from_millis(1), + ); + assert!(held.active); + assert!(!held.tainted); + + let expired = agent_caused_pty_activity_active( + 2, + 1, + &mut tracker, + now + AGENT_PTY_ACTIVITY_WINDOW + std::time::Duration::from_millis(1), + ); + assert!(!expired.active); + assert!(!expired.tainted); + } + + #[test] + fn input_taint_discards_pty_activity_until_fresh_post_taint_output() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let tainted = agent_caused_pty_activity_active(2, 2, &mut tracker, now); + assert!(!tainted.active); + assert!(tainted.tainted); + + let after_taint = agent_caused_pty_activity_active( + 2, + 2, + &mut tracker, + now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(1), + ); + assert!(!after_taint.active); + assert!(!after_taint.tainted); + assert!(after_taint.taint_just_ended); + + let settled = agent_caused_pty_activity_active( + 2, + 2, + &mut tracker, + now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(2), + ); + assert!(!settled.active); + assert!(!settled.tainted); + assert!(!settled.taint_just_ended); + + let fresh_output = agent_caused_pty_activity_active( + 3, + 2, + &mut tracker, + now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(3), + ); + assert!(fresh_output.active); + assert!(!fresh_output.tainted); + } + + #[test] + fn skipped_update_consumes_expired_taint_edge() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + let mut lease = PostTaintWorkingLease::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let tainted = agent_caused_pty_activity_active(2, 2, &mut tracker, now); + assert!(tainted.tainted); + + handle_skipped_detection_update( + AgentState::Idle, + Some(tainted), + &mut lease, + &mut tracker, + 2, + 2, + now, + ); + + let after_skip = agent_caused_pty_activity_active( + 2, + 2, + &mut tracker, + now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(1), + ); + assert!(!after_skip.active); + assert!(!after_skip.tainted); + assert!(!after_skip.taint_just_ended); + } + + #[test] + fn skipped_working_update_starts_post_taint_lease_on_taint_edge() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + let mut lease = PostTaintWorkingLease::default(); + + handle_skipped_detection_update( + AgentState::Working, + Some(pty_activity_after_taint(10)), + &mut lease, + &mut tracker, + 10, + 2, + now, + ); + + let previous = publish_state(AgentState::Working); + let idle = publish_state(AgentState::Idle); + + assert!(lease.should_hold_working_to_idle( + previous, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + std::time::Duration::from_millis(1) + )); + } + + #[test] + fn startup_grace_rebaseline_discards_accumulated_pty_activity() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let startup_render = agent_caused_pty_activity_active(2, 1, &mut tracker, now); + assert!(startup_render.active); + + baseline_pty_causality(&mut tracker, 2, 1); + let after_grace = + agent_caused_pty_activity_active(2, 1, &mut tracker, now + AGENT_STARTUP_GRACE_WINDOW); + + assert!(!after_grace.active); + assert!(!after_grace.tainted); + } + + #[test] + fn transition_decision_publishes_next_for_plain_state_change() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + let mut blocked = publish_state(AgentState::Blocked); + blocked.visible_blocker = true; + + assert_eq!( + decide_detection_transition( + transition_input( + publish_state(AgentState::Idle), + blocked, + Some(pty_activity(false, false, 10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionTransitionDecision::PublishNext + ); + } + + #[test] + fn transition_decision_holds_idle_to_working_pending_confirmation() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + + assert_eq!( + decide_detection_transition( + transition_input( + publish_state(AgentState::Idle), + publish_state(AgentState::Working), + Some(pty_activity(true, true, 10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionTransitionDecision::NoPublish + ); + assert!(pending_working.active()); + } + + #[test] + fn transition_decision_publishes_held_working_before_exit() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + + assert_eq!( + decide_detection_transition( + transition_input( + publish_state(AgentState::Idle), + publish_state(AgentState::Working), + Some(pty_activity(true, true, 10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionTransitionDecision::NoPublish + ); + + let mut input = transition_input( + publish_state(AgentState::Idle), + publish_state(AgentState::Idle), + Some(pty_activity(false, false, 10)), + now + std::time::Duration::from_millis(1), + ); + input.process_exited = true; + + assert_eq!( + decide_detection_transition( + input, + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionTransitionDecision::PublishHeldWorkingBeforeExit + ); + assert!(!pending_working.active()); + } + + #[test] + fn transition_decision_holds_working_to_idle_after_taint() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + + assert_eq!( + decide_detection_transition( + transition_input( + publish_state(AgentState::Working), + publish_state(AgentState::Idle), + Some(pty_activity_after_taint(10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionTransitionDecision::NoPublish + ); + assert!(post_taint_working.active()); + } + + #[test] + fn screen_publish_prefers_active_pty_working_over_screen_blocker() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + let mut blocked = screen_detection(AgentState::Blocked); + blocked.visible_blocker = true; + + assert_eq!( + decide_screen_detection_publish( + screen_publish_input( + AgentState::Blocked, + blocked, + Some(pty_activity(true, true, 10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionPublishDecision::Publish { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + process_exited: false, + } + ); + } + + #[test] + fn screen_publish_downgrades_visible_working_to_idle_when_pty_is_quiet() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + + assert_eq!( + decide_screen_detection_publish( + screen_publish_input( + AgentState::Blocked, + screen_detection(AgentState::Working), + Some(pty_activity(false, false, 10)), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionPublishDecision::Publish { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + } + ); + } + + #[test] + fn screen_publish_freezes_during_taint() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); + let mut post_taint_working = PostTaintWorkingLease::default(); + + assert_eq!( + decide_screen_detection_publish( + screen_publish_input( + AgentState::Working, + screen_detection(AgentState::Idle), + Some(PtyActivitySignal { + active: false, + tainted: true, + taint_just_ended: false, + fresh_output: false, + output_seq: 10, + }), + now, + ), + &mut pending_idle, + &mut pending_working, + &mut post_taint_working, + ), + DetectionPublishDecision::NoPublish + ); + } +} |
