From 0efd8eaad1620dc4b9e44c45ee16ef0a9807a200 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 10 Jun 2026 21:00:56 +0300 Subject: [PATCH] fix: remove pty taint from agent detection --- docs/next/CHANGELOG.md | 1 + src/pane.rs | 216 +----- src/pane/agent_detection.rs | 1433 ++--------------------------------- src/terminal/state.rs | 2 +- 4 files changed, 128 insertions(+), 1524 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 17e0f2e6..3eb383fa 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Agent state detection for non-authoritative agents now comes from screen manifests instead of PTY-first semantic arbitration, so terminal output activity no longer publishes `working`, vetoes visible blockers, or decides idle fallback. +- Removed the remaining PTY input-taint debounce from agent detection, so user input, pane resizes, and redraw nudges no longer delay screen/OSC manifest state updates. - Numeric keypad keys that send VT100 application-keypad escape sequences now enter their digits and operators instead of being dropped. (#493) - Codex panes now stay marked working when the live status header uses reasoning-summary text such as `Investigating code output` instead of the literal `Working` label. (#501) - Native pane URL clicks now use Cmd-click on macOS and Ctrl-click on other platforms. diff --git a/src/pane.rs b/src/pane.rs index b0f3bbfa..a5709ebb 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -32,12 +32,11 @@ 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_with_osc, - handle_skipped_detection_update, observe_pty_output_activity, DetectionPublishDecision, - DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, - PendingWorkingConfirmation, PostTaintWorkingLease, PtyCausalityTracker, - ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, + decide_detection_screen_read, decide_screen_detection_publish, + detection_update_for_publish_with_osc, mark_detection_content_changed, + observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, + DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, + AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome}; @@ -384,8 +383,7 @@ fn spawn_basic_detection_task( pane_id: PaneId, child_pid: Arc, terminal: Arc, - pty_output_seq: Arc, - input_write_seq: Arc, + detection_content_seq: Arc, full_lifecycle_authority_active: Arc, state_events: mpsc::Sender, ) -> ( @@ -414,18 +412,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 last_screen_scan_detection_content_seq = None; 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(); - let sleep_duration = if pending_working.active() { - pending_working.recheck_delay(now_for_sleep) - } else if pending_idle.active() { + let sleep_duration = if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else { std::time::Duration::from_millis(300) @@ -448,12 +440,9 @@ 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(); + last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); } } @@ -541,19 +530,12 @@ fn spawn_basic_detection_task( agent_changed = previous_agent != agent; if agent_changed { pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); - last_screen_scan_pty_output_seq = None; + last_screen_scan_detection_content_seq = None; // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - baseline_pty_causality( - &mut pty_causality, - pty_output_seq.load(Ordering::Relaxed), - input_write_seq.load(Ordering::Relaxed), - ); state = AgentState::Idle; last_visible_idle = true; last_visible_blocker = false; @@ -583,8 +565,6 @@ fn spawn_basic_detection_task( if full_lifecycle_authority_active.load(Ordering::Acquire) && !process_exited { pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); continue; } @@ -592,36 +572,20 @@ fn spawn_basic_detection_task( if process_exited { 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( - &mut pty_causality, - pty_output_seq.load(Ordering::Relaxed), - input_write_seq.load(Ordering::Relaxed), - ); agent_startup_grace_until = None; - last_screen_scan_pty_output_seq = None; + last_screen_scan_detection_content_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, - )) + let current_detection_content_seq = if agent.is_some() { + Some(detection_content_seq.load(Ordering::Relaxed)) } else { None }; @@ -629,33 +593,21 @@ fn spawn_basic_detection_task( 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, + current_detection_content_seq, + last_screen_scan_detection_content_seq, }) { DetectionScreenReadDecision::Read => {} DetectionScreenReadDecision::Skip => continue, } let content = terminal.detection_text(); - last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq); + last_screen_scan_detection_content_seq = current_detection_content_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; } sync_content_change_acquisition( @@ -677,17 +629,7 @@ fn spawn_basic_detection_task( &osc_progress, process_exited, ) else { - 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; }; match decide_screen_detection_publish( @@ -700,12 +642,9 @@ fn spawn_basic_detection_task( last_visible_signal_refresh, process_exited, agent_changed, - pty_activity, now, }, &mut pending_idle, - &mut pending_working, - &mut post_taint_working, ) { DetectionPublishDecision::NoPublish => {} DetectionPublishDecision::Publish { @@ -807,7 +746,7 @@ pub struct PaneRuntime { reported_cwd: Arc>>, child_wait_completed: Option>, kitty_keyboard_flags: Arc, - input_write_seq: Arc, + detection_content_seq: Arc, full_lifecycle_authority_active: Arc, detect_reset_notify: Arc, pending_release: Arc>>, @@ -1551,25 +1490,24 @@ impl PaneRuntime { let child_pid = Arc::new(AtomicU32::new(child_pid)); let reported_cwd = Arc::new(Mutex::new(None)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); - let input_write_seq = Arc::new(AtomicU64::new(0)); - let pty_output_seq = Arc::new(AtomicU64::new(0)); + let detection_content_seq = Arc::new(AtomicU64::new(0)); let io = { let terminal = terminal.clone(); let response_writer = response_tx.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); - let pty_output_seq = pty_output_seq.clone(); + let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let read_events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let delay_rt = rt.clone(); let on_read = Box::new(move |bytes: &[u8]| { - observe_pty_output_activity(bytes, &pty_output_seq); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); + observe_detection_content_change(bytes, &detection_content_seq); if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { render_notify.notify_one(); } @@ -1618,8 +1556,7 @@ impl PaneRuntime { pane_id, child_pid.clone(), terminal.clone(), - pty_output_seq, - input_write_seq.clone(), + detection_content_seq.clone(), full_lifecycle_authority_active.clone(), events, ); @@ -1633,7 +1570,7 @@ impl PaneRuntime { reported_cwd, child_wait_completed: None, kitty_keyboard_flags, - input_write_seq, + detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, pending_release, @@ -1683,8 +1620,7 @@ impl PaneRuntime { let child_pid = Arc::new(AtomicU32::new(0)); let reported_cwd = Arc::new(Mutex::new(None)); let child_wait_completed = Arc::new(AtomicBool::new(false)); - let input_write_seq = Arc::new(AtomicU64::new(0)); - let pty_output_seq = Arc::new(AtomicU64::new(0)); + let detection_content_seq = Arc::new(AtomicU64::new(0)); let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); { let child_pid = child_pid.clone(); @@ -1717,16 +1653,16 @@ impl PaneRuntime { let response_writer = response_tx.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); - let pty_output_seq = pty_output_seq.clone(); + let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let on_read = Box::new(move |bytes: &[u8]| { - observe_pty_output_activity(bytes, &pty_output_seq); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); + observe_detection_content_change(bytes, &detection_content_seq); if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { render_notify.notify_one(); } @@ -1780,8 +1716,7 @@ impl PaneRuntime { let child_pid = child_pid.clone(); let terminal = terminal.clone(); let state_events = events.clone(); - let pty_output_seq = pty_output_seq.clone(); - let input_write_seq_for_task = input_write_seq.clone(); + let detection_content_seq = detection_content_seq.clone(); let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); @@ -1808,12 +1743,9 @@ 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 last_screen_scan_detection_content_seq = None; 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; @@ -1824,8 +1756,6 @@ impl PaneRuntime { || terminal.has_transient_default_color_override() { TICK_PENDING_RELEASE - } else if pending_working.active() { - pending_working.recheck_delay(now_for_tick) } else if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else if agent_presence.current_agent().is_none() { @@ -1851,12 +1781,9 @@ 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(); + last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); } } @@ -1951,20 +1878,13 @@ impl PaneRuntime { agent = agent_presence.current_agent(); if agent != previous_agent { pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); - last_screen_scan_pty_output_seq = None; + last_screen_scan_detection_content_seq = None; // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - baseline_pty_causality( - &mut pty_causality, - pty_output_seq.load(Ordering::Relaxed), - input_write_seq_for_task.load(Ordering::Relaxed), - ); state = AgentState::Idle; last_visible_idle = true; last_visible_blocker = false; @@ -2025,45 +1945,27 @@ impl PaneRuntime { && !process_exited { pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); continue; } if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; - last_screen_scan_pty_output_seq = None; + last_screen_scan_detection_content_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( - &mut pty_causality, - pty_output_seq.load(Ordering::Relaxed), - input_write_seq_for_task.load(Ordering::Relaxed), - ); 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, - )) + let current_detection_content_seq = if agent.is_some() { + Some(detection_content_seq.load(Ordering::Relaxed)) } else { None }; @@ -2071,33 +1973,21 @@ impl PaneRuntime { 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, + current_detection_content_seq, + last_screen_scan_detection_content_seq, }) { DetectionScreenReadDecision::Read => {} DetectionScreenReadDecision::Skip => continue, } let content = terminal.detection_text(); - last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq); + last_screen_scan_detection_content_seq = current_detection_content_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; } sync_content_change_acquisition( @@ -2119,17 +2009,7 @@ impl PaneRuntime { &osc_progress, process_exited, ) else { - 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; }; match decide_screen_detection_publish( @@ -2142,12 +2022,9 @@ impl PaneRuntime { last_visible_signal_refresh, process_exited, agent_changed, - pty_activity, now, }, &mut pending_idle, - &mut pending_working, - &mut post_taint_working, ) { DetectionPublishDecision::NoPublish => {} DetectionPublishDecision::Publish { @@ -2193,7 +2070,7 @@ impl PaneRuntime { reported_cwd, child_wait_completed: Some(child_wait_completed), kitty_keyboard_flags, - input_write_seq, + detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, pending_release, @@ -2242,11 +2119,11 @@ impl PaneRuntime { if self.current_size.get() == size { return; } - self.input_write_seq.fetch_add(1, Ordering::Relaxed); self.current_size.set(size); let terminal_responses = self .terminal .resize(rows, cols, cell_width_px, cell_height_px); + mark_detection_content_changed(&self.detection_content_seq); self.io.resize( rows, cols, @@ -2258,7 +2135,6 @@ impl PaneRuntime { #[cfg(unix)] pub fn nudge_child_redraw_after_handoff(&self) { - self.input_write_seq.fetch_add(1, Ordering::Relaxed); let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get(); self.io .nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px); @@ -2397,19 +2273,11 @@ impl PaneRuntime { } pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - let result = self.io.send_bytes(bytes).await; - if result.is_ok() { - self.input_write_seq.fetch_add(1, Ordering::Relaxed); - } - result + self.io.send_bytes(bytes).await } pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { - let result = self.io.try_send_bytes(bytes); - if result.is_ok() { - self.input_write_seq.fetch_add(1, Ordering::Relaxed); - } - result + self.io.try_send_bytes(bytes) } pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { @@ -2607,7 +2475,7 @@ impl PaneRuntime { reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), - input_write_seq: Arc::new(AtomicU64::new(0)), + detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), @@ -2981,7 +2849,7 @@ mod tests { reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), - input_write_seq: Arc::new(AtomicU64::new(0)), + detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), @@ -3012,7 +2880,7 @@ mod tests { reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), - input_write_seq: Arc::new(AtomicU64::new(0)), + detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index d891c284..9ef10dfe 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -2,24 +2,11 @@ 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_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 = @@ -55,7 +42,6 @@ impl PendingIdleConfirmation { next: DetectionPublishState, agent_changed: bool, process_exited: bool, - _pty_signal: Option, now: std::time::Instant, ) -> bool { let is_working_to_plain_idle = previous.state == AgentState::Working @@ -91,169 +77,29 @@ impl PendingIdleConfirmation { } } -#[derive(Debug, Default)] -pub(super) struct PendingWorkingConfirmation { - started_at: Option, - 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, - 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; - } - - if next.visible_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, -} - -impl PostTaintWorkingLease { - pub(super) fn active(&self) -> bool { - self.until.is_some() - } - - 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, - _now: std::time::Instant, - ) -> bool { - self.clear(); - false - } -} - #[derive(Debug, Clone, Copy)] pub(super) struct IdleScreenScanSkipInput { pub(super) state: AgentState, pub(super) agent: Option, 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, - pub(super) last_screen_scan_pty_output_seq: Option, + pub(super) current_detection_content_seq: Option, + pub(super) last_screen_scan_detection_content_seq: Option, } 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) + input.current_detection_content_seq.is_some() + && input.last_screen_scan_detection_content_seq == input.current_detection_content_seq } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -267,12 +113,10 @@ pub(super) struct DetectionScreenReadInput { pub(super) state: AgentState, pub(super) agent: Option, 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, - pub(super) last_screen_scan_pty_output_seq: Option, + pub(super) current_detection_content_seq: Option, + pub(super) last_screen_scan_detection_content_seq: Option, } pub(super) fn decide_detection_screen_read( @@ -282,12 +126,10 @@ pub(super) fn decide_detection_screen_read( 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, + current_detection_content_seq: input.current_detection_content_seq, + last_screen_scan_detection_content_seq: input.last_screen_scan_detection_content_seq, }) { DetectionScreenReadDecision::Skip } else { @@ -328,7 +170,6 @@ pub(super) fn stable_visible_signal_refresh_due( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum DetectionTransitionDecision { NoPublish, - PublishHeldWorkingBeforeExit, PublishNext, } @@ -338,7 +179,6 @@ pub(super) struct DetectionTransitionInput { pub(super) next_publish: DetectionPublishState, pub(super) agent_changed: bool, pub(super) process_exited: bool, - pub(super) pty_activity: Option, pub(super) stable_refresh_due: bool, pub(super) now: std::time::Instant, } @@ -346,55 +186,14 @@ pub(super) struct DetectionTransitionInput { pub(super) fn decide_detection_transition( input: DetectionTransitionInput, pending_idle: &mut PendingIdleConfirmation, - pending_working: &mut PendingWorkingConfirmation, - post_taint_working: &mut PostTaintWorkingLease, ) -> DetectionTransitionDecision { - 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; } @@ -433,15 +232,12 @@ pub(super) struct ScreenDetectionPublishInput { pub(super) screen_detection: AgentDetection, pub(super) process_exited: bool, pub(super) agent_changed: bool, - pub(super) pty_activity: Option, 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 detection = input.screen_detection; let new_state = crate::terminal::state::stabilize_agent_detection(detection); @@ -449,16 +245,6 @@ pub(super) fn decide_screen_detection_publish( let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; let visible_working = detection.visible_working && new_state == AgentState::Working; - if input.pty_activity.is_some_and(|signal| signal.tainted) - && new_state == AgentState::Idle - && !input.process_exited - { - pending_idle.clear(); - pending_working.clear(); - post_taint_working.clear(); - return DetectionPublishDecision::NoPublish; - } - let previous_publish = DetectionPublishState { state: input.current_state, visible_idle: input.last_visible_idle, @@ -484,24 +270,12 @@ pub(super) fn decide_screen_detection_publish( next_publish, agent_changed: input.agent_changed, process_exited: input.process_exited, - pty_activity: input.pty_activity, stable_refresh_due, now: input.now, }, pending_idle, - pending_working, - post_taint_working, ) { DetectionTransitionDecision::NoPublish => DetectionPublishDecision::NoPublish, - DetectionTransitionDecision::PublishHeldWorkingBeforeExit => { - DetectionPublishDecision::Publish { - state: AgentState::Working, - visible_idle: false, - visible_blocker: false, - visible_working: false, - process_exited: false, - } - } DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish { state: new_state, visible_idle, @@ -542,117 +316,14 @@ pub(super) fn detection_update_for_publish_with_osc( (!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, - last_agent_pty_at: Option, -} - -#[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) { +pub(super) fn observe_detection_content_change(bytes: &[u8], detection_content_seq: &AtomicU64) { if !bytes.is_empty() { - pty_output_seq.fetch_add(1, Ordering::Relaxed); + detection_content_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, - post_taint_working: &mut PostTaintWorkingLease, - tracker: &mut PtyCausalityTracker, - pty_output_seq: u64, - input_seq: u64, - _now: std::time::Instant, -) { - if state == AgentState::Working { - return; - } - - post_taint_working.clear(); - consume_skipped_pty_causality(tracker, pty_output_seq, input_seq); +pub(super) fn mark_detection_content_changed(detection_content_seq: &AtomicU64) { + detection_content_seq.fetch_add(1, Ordering::Relaxed); } #[cfg(test)] @@ -668,53 +339,6 @@ mod tests { } } - 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, - now: std::time::Instant, - ) -> DetectionTransitionInput { - DetectionTransitionInput { - previous_publish, - next_publish, - agent_changed: false, - process_exited: false, - pty_activity, - stable_refresh_due: false, - now, - } - } - fn screen_detection(state: AgentState) -> AgentDetection { AgentDetection { state, @@ -728,7 +352,6 @@ mod tests { fn screen_publish_input( current_state: AgentState, screen_detection: AgentDetection, - pty_activity: Option, now: std::time::Instant, ) -> ScreenDetectionPublishInput { ScreenDetectionPublishInput { @@ -740,251 +363,67 @@ mod tests { screen_detection, process_exited: false, agent_changed: false, - pty_activity, now, } } - fn screen_read_input( - state: AgentState, - pty_activity: PtyActivitySignal, - ) -> DetectionScreenReadInput { - screen_read_input_for_agent(Some(Agent::Codex), state, pty_activity) - } - - fn screen_read_input_for_agent( - agent: Option, - state: AgentState, - pty_activity: PtyActivitySignal, - ) -> DetectionScreenReadInput { + fn screen_read_input(state: AgentState, current_seq: u64) -> DetectionScreenReadInput { DetectionScreenReadInput { state, - agent, + 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), + current_detection_content_seq: Some(current_seq), + last_screen_scan_detection_content_seq: Some(10), } } #[test] - fn screen_read_decision_skips_only_stable_idle_quiet_output() { + fn screen_read_skips_unchanged_idle_bottom_buffer() { assert_eq!( - decide_detection_screen_read(screen_read_input( - AgentState::Idle, - pty_activity(false, false, 10), - )), + decide_detection_screen_read(screen_read_input(AgentState::Idle, 10)), DetectionScreenReadDecision::Skip ); + } + #[test] + fn screen_read_reads_when_idle_bottom_buffer_changes() { 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), - )), + decide_detection_screen_read(screen_read_input(AgentState::Idle, 11)), DetectionScreenReadDecision::Read ); } #[test] - fn screen_read_decision_reads_screen_for_active_pty() { - assert_eq!( - decide_detection_screen_read(screen_read_input( - AgentState::Working, - pty_activity(true, true, 11), - )), - DetectionScreenReadDecision::Read - ); - - assert_eq!( - decide_detection_screen_read(screen_read_input( - AgentState::Blocked, - pty_activity(true, true, 11), - )), - DetectionScreenReadDecision::Read - ); - - assert_eq!( - decide_detection_screen_read(screen_read_input( - AgentState::Idle, - pty_activity(true, true, 11), - )), - DetectionScreenReadDecision::Read - ); - - assert_eq!( - decide_detection_screen_read(screen_read_input_for_agent( - Some(Agent::Claude), - 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)); + fn screen_read_reads_for_transitions_and_missing_agent() { + let mut input = screen_read_input(AgentState::Idle, 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 screen_read_decision_reads_during_active_pty_pending_working() { - let mut input = screen_read_input(AgentState::Idle, pty_activity(true, true, 11)); - input.pending_working_active = true; + let mut input = screen_read_input(AgentState::Idle, 10); + input.agent_changed = true; assert_eq!( decide_detection_screen_read(input), DetectionScreenReadDecision::Read ); - let mut input = screen_read_input_for_agent( - Some(Agent::Claude), - AgentState::Idle, - pty_activity(true, true, 11), - ); - input.pending_working_active = true; + let mut input = screen_read_input(AgentState::Idle, 10); + input.process_exited = 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 process_exit_overrides_transcript_viewer_skip() { - 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"; - - let detection = detection_update_for_publish(Some(Agent::Codex), content, true) - .expect("process exit should publish idle even inside transcript viewer"); - assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); - assert!(!detection.skip_state_update); - } - - #[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_idle: false, - visible_blocker: false, - visible_working: false, - }; - let refresh_due = stable_visible_signal_refresh_due( - previous, - previous, - Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), - now, + let mut input = screen_read_input(AgentState::Idle, 10); + input.agent = None; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read ); - - 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_idle: false, - 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_idle: false, - 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] @@ -994,13 +433,12 @@ mod tests { 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, 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( @@ -1008,7 +446,6 @@ mod tests { next, false, false, - None, now + AGENT_PENDING_IDLE_RECHECK * 2 )); assert!(!pending.should_hold_working_to_idle( @@ -1016,716 +453,78 @@ mod tests { next, false, false, - None, now + AGENT_PENDING_IDLE_RECHECK * 3 )); } #[test] - fn pending_idle_cap_publishes_idle_when_still_quiet() { + fn visible_idle_bypasses_plain_idle_hold() { let now = std::time::Instant::now(); let previous = publish_state(AgentState::Working); - let next = publish_state(AgentState::Idle); + let mut next = publish_state(AgentState::Idle); + next.visible_idle = true; 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 - )); + assert!(!pending.should_hold_working_to_idle(previous, next, false, false, now)); } #[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_holds_plain_idle_fallback_even_when_pty_is_quiet() { - 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_never_holds_working_to_idle() { - 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.active()); - } - - #[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 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.active()); - 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() { + fn transition_decision_publishes_next_for_visible_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 = 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)), + DetectionTransitionInput { + previous_publish: publish_state(AgentState::Idle), + next_publish: blocked, + agent_changed: false, + process_exited: false, + stable_refresh_due: false, now, - ), + }, &mut pending_idle, - &mut pending_working, - &mut post_taint_working, ), DetectionTransitionDecision::PublishNext ); } #[test] - fn transition_decision_holds_idle_to_working_pending_confirmation() { + fn screen_publish_keeps_visible_working_without_pty_activity() { 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_keeps_visible_blocker_during_active_pty() { - 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, - ), + screen_publish_input(AgentState::Idle, screen_detection(AgentState::Working), now,), &mut pending_idle, - &mut pending_working, - &mut post_taint_working, ), DetectionPublishDecision::Publish { - state: AgentState::Blocked, + state: AgentState::Working, visible_idle: false, - visible_blocker: true, + visible_blocker: false, + visible_working: true, + process_exited: false, + } + ); + } + + #[test] + fn screen_publish_can_publish_idle_without_input_taint_delay() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + + assert_eq!( + decide_screen_detection_publish( + screen_publish_input(AgentState::Blocked, screen_detection(AgentState::Idle), now,), + &mut pending_idle, + ), + DetectionPublishDecision::Publish { + state: AgentState::Idle, + visible_idle: true, + visible_blocker: false, visible_working: false, process_exited: false, } @@ -1733,89 +532,25 @@ mod tests { } #[test] - fn screen_publish_keeps_visible_working_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(); + fn detection_content_change_tracks_raw_nonempty_reads_for_scan_scheduling() { + let seq = AtomicU64::new(0); - 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::Working, - visible_idle: false, - visible_blocker: false, - visible_working: true, - process_exited: false, - } - ); + observe_detection_content_change(b"", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 0); + + observe_detection_content_change(b"\x1b[?2026h", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 1); + + observe_detection_content_change(b"body bytes", &seq); + assert_eq!(seq.load(Ordering::Relaxed), 2); } #[test] - fn screen_publish_keeps_visible_working_during_active_pty() { - 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(); + fn local_terminal_mutations_can_invalidate_idle_scan_skip() { + let seq = AtomicU64::new(0); - assert_eq!( - decide_screen_detection_publish( - screen_publish_input( - AgentState::Idle, - screen_detection(AgentState::Working), - Some(pty_activity(true, true, 10)), - now, - ), - &mut pending_idle, - &mut pending_working, - &mut post_taint_working, - ), - DetectionPublishDecision::Publish { - state: AgentState::Working, - visible_idle: false, - visible_blocker: false, - visible_working: true, - process_exited: false, - } - ); - } + mark_detection_content_changed(&seq); - #[test] - fn screen_publish_freezes_idle_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 - ); + assert_eq!(seq.load(Ordering::Relaxed), 1); } } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 25a48e5f..f2de1342 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1733,7 +1733,7 @@ mod tests { } #[test] - fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { + fn detected_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle);