aboutsummaryrefslogtreecommitdiff
path: root/src/agent_detection_policy.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/agent_detection_policy.rs')
-rw-r--r--src/agent_detection_policy.rs288
1 files changed, 288 insertions, 0 deletions
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<Agent>,
+ pub screen_detection: AgentDetection,
+ pub process_exited: bool,
+ pub startup_grace_active: bool,
+ pub pty_signal: Option<PtySignal>,
+}
+
+#[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,
+ })
+ );
+ }
+}