aboutsummaryrefslogtreecommitdiff
path: root/src/pane
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 02:28:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 04:26:19 +0300
commit55f5a3b5d4ab90b7df207531d3cd3cd5f0923616 (patch)
treec95a832028ad60ac9a74f1cec97a6c06640787f8 /src/pane
parentd5ccf19a9d0d47cecb216038f917554b236aaa30 (diff)
refactor: split agent detection policy from pane runtime
Diffstat (limited to 'src/pane')
-rw-r--r--src/pane/agent_detection.rs1886
1 files changed, 1886 insertions, 0 deletions
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
+ );
+ }
+}