From ee51c5e5fbaea694e65bb0a4908f75bcaf9707d1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 8 Jun 2026 20:55:51 +0300 Subject: feat: use pty-first agent detection --- src/agent_detection_policy.rs | 288 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/agent_detection_policy.rs (limited to 'src/agent_detection_policy.rs') diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs new file mode 100644 index 00000000..a0d7ba4c --- /dev/null +++ b/src/agent_detection_policy.rs @@ -0,0 +1,288 @@ +use crate::detect::{Agent, AgentDetection, AgentState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PtySignal { + pub active: bool, + pub tainted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DetectionPolicyInput { + pub agent: Option, + pub screen_detection: AgentDetection, + pub process_exited: bool, + pub startup_grace_active: bool, + pub pty_signal: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DetectionPolicyDecision { + Publish(AgentDetection), + Freeze, +} + +fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection { + if detection.visible_blocker { + return AgentDetection { + state: AgentState::Blocked, + skip_state_update: false, + visible_blocker: true, + visible_working: false, + }; + } + + AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + } +} + +#[cfg(test)] +pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool { + matches!( + agent, + Agent::Pi | Agent::GithubCopilot | Agent::Kimi | Agent::Hermes | Agent::Qodercli + ) +} + +pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { + matches!( + (source, agent_label), + ("herdr:pi", "pi") + | ("herdr:omp", "omp") + | ("herdr:kimi", "kimi") + | ("herdr:copilot", "copilot") + | ("herdr:hermes", "hermes") + | ("herdr:qodercli", "qodercli") + ) +} + +pub(crate) fn apply_detection_policy(input: DetectionPolicyInput) -> DetectionPolicyDecision { + if input.process_exited { + return DetectionPolicyDecision::Publish(input.screen_detection); + } + + if input.startup_grace_active { + return DetectionPolicyDecision::Freeze; + } + + if input.agent.is_none() { + return DetectionPolicyDecision::Publish(input.screen_detection); + }; + + let Some(pty_signal) = input.pty_signal else { + return DetectionPolicyDecision::Publish(input.screen_detection); + }; + + if pty_signal.tainted { + return DetectionPolicyDecision::Freeze; + } + + if pty_signal.active { + return DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }); + } + + DetectionPolicyDecision::Publish(screen_blocked_or_idle_fallback(input.screen_detection)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detection(state: AgentState) -> AgentDetection { + AgentDetection { + state, + skip_state_update: false, + visible_blocker: false, + visible_working: state == AgentState::Working, + } + } + + fn input(screen_detection: AgentDetection) -> DetectionPolicyInput { + DetectionPolicyInput { + agent: Some(Agent::Codex), + screen_detection, + process_exited: false, + startup_grace_active: false, + pty_signal: Some(PtySignal { + active: false, + tainted: false, + }), + } + } + + #[test] + fn classifies_full_lifecycle_hook_sources() { + assert!(full_lifecycle_hook_authority("herdr:pi", "pi")); + assert!(full_lifecycle_hook_authority("herdr:omp", "omp")); + assert!(full_lifecycle_hook_authority("herdr:kimi", "kimi")); + assert!(full_lifecycle_hook_authority("herdr:copilot", "copilot")); + assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes")); + assert!(full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); + assert!(!full_lifecycle_hook_authority("herdr:codex", "codex")); + assert!(!full_lifecycle_hook_authority("herdr:claude", "claude")); + assert!(!full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(!full_lifecycle_hook_authority("custom", "pi")); + } + + #[test] + fn classifies_full_lifecycle_detected_agents_without_omp_variant() { + assert!(full_lifecycle_detected_agent(Agent::Pi)); + assert!(full_lifecycle_detected_agent(Agent::Kimi)); + assert!(full_lifecycle_detected_agent(Agent::GithubCopilot)); + assert!(full_lifecycle_detected_agent(Agent::Hermes)); + assert!(full_lifecycle_detected_agent(Agent::Qodercli)); + assert!(!full_lifecycle_detected_agent(Agent::Codex)); + assert!(!full_lifecycle_detected_agent(Agent::Claude)); + } + + #[test] + fn startup_grace_freezes_publish() { + let mut input = input(detection(AgentState::Working)); + input.startup_grace_active = true; + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn taint_freezes_weak_publish() { + let mut input = input(detection(AgentState::Idle)); + input.pty_signal = Some(PtySignal { + active: false, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn taint_freezes_visible_blocker_until_pty_is_quiet() { + let mut blocker = detection(AgentState::Blocked); + blocker.visible_blocker = true; + let mut input = input(blocker); + input.pty_signal = Some(PtySignal { + active: false, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn process_exit_publishes_even_during_taint() { + let mut input = input(detection(AgentState::Idle)); + input.process_exited = true; + input.pty_signal = Some(PtySignal { + active: true, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(detection(AgentState::Idle)) + ); + } + + #[test] + fn pty_activity_publishes_working_without_inventing_visible_working() { + let mut input = input(detection(AgentState::Idle)); + input.pty_signal = Some(PtySignal { + active: true, + tainted: false, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn active_pty_wins_over_visible_blocker() { + let mut blocker = detection(AgentState::Blocked); + blocker.visible_blocker = true; + let mut input = input(blocker); + input.pty_signal = Some(PtySignal { + active: true, + tainted: false, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_with_visible_blocker_publishes_blocked() { + let mut screen = detection(AgentState::Blocked); + screen.visible_blocker = true; + let input = input(screen); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Blocked, + skip_state_update: false, + visible_blocker: true, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_downgrades_screen_working_to_idle() { + let input = input(detection(AgentState::Working)); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_downgrades_weak_screen_blocked_to_idle() { + let input = input(detection(AgentState::Blocked)); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } +} -- cgit v1.2.3