aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/pane.rs1515
-rw-r--r--src/pane/agent_detection.rs1886
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
+ );
+ }
+}