diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-08 20:55:51 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 04:26:19 +0300 |
| commit | ee51c5e5fbaea694e65bb0a4908f75bcaf9707d1 (patch) | |
| tree | f566d2a5921d4112602a7abcffb5cf6eb04d5f24 /src/terminal | |
| parent | a7cd7806f2fa0251e2dca41ffec61ceafb6f0e18 (diff) | |
feat: use pty-first agent detection
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/metadata.rs | 7 | ||||
| -rw-r--r-- | src/terminal/state.rs | 1052 |
2 files changed, 657 insertions, 402 deletions
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 1dcb80c0..2666b32f 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -352,7 +352,7 @@ impl TerminalState { fn effective_custom_status_for_state_at_with_ttl( &self, - state: AgentState, + _state: AgentState, now: Instant, enforce_ttl: bool, ) -> Option<String> { @@ -360,10 +360,7 @@ impl TerminalState { return Some(custom_status); } - if self.visible_blocker_overrides_hook() - || self.visible_working_overrides_hook() - || self.visible_idle_masks_hook_custom_status(state, now) - { + if self.visible_blocker_overrides_hook() { return None; } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index eeff4c49..afd94ecf 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::time::{Duration, Instant}; +#[cfg(test)] +use std::time::Duration; +use std::time::Instant; -// Effective state arbitration is intentionally centralized here. Hooks are the -// default authority for agent-owned internal state, but a narrow set of strong -// visible screen signals can veto stale hook reports. Precedence is: -// strong visible blocker > visible working/idle recovery > hook > fallback. +// Effective state arbitration is intentionally centralized here. Full lifecycle +// Herdr hook integrations are hook-authoritative while live; screen recovery +// remains only for session-only/custom hook paths and fallback detection. // Process-exit updates clear matching hook authority before recomputing state. use crate::detect::{Agent, AgentState}; @@ -15,9 +16,6 @@ use crate::terminal::TerminalId; mod metadata; pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation}; -const SCREEN_WORKING_HOLD: Duration = Duration::from_millis(1200); -const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2); - #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookAuthority { pub source: String, @@ -30,6 +28,12 @@ pub struct HookAuthority { } #[derive(Debug, Clone, PartialEq, Eq)] +struct SuppressedFullLifecycleHookReport { + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option<String>, pub previous_known_agent: Option<Agent>, @@ -58,16 +62,14 @@ pub struct TerminalState { pub detected_agent: Option<Agent>, pub fallback_state: AgentState, fallback_visible_blocker: bool, - fallback_visible_idle: bool, - fallback_visible_working: bool, fallback_observed_at: Option<Instant>, - stale_hook_idle_since: Option<Instant>, pub hook_authority: Option<HookAuthority>, pub agent_metadata: HashMap<String, AgentMetadata>, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, pub manual_label: Option<String>, pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, + suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, metadata_report_sequences: HashMap<String, u64>, pub state: AgentState, pub revision: u64, @@ -84,16 +86,14 @@ impl TerminalState { detected_agent: None, fallback_state: AgentState::Unknown, fallback_visible_blocker: false, - fallback_visible_idle: false, - fallback_visible_working: false, fallback_observed_at: None, - stale_hook_idle_since: None, hook_authority: None, agent_metadata: HashMap::new(), persisted_agent_session: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), + suppressed_full_lifecycle_hook_reports: HashMap::new(), metadata_report_sequences: HashMap::new(), state: AgentState::Unknown, revision: 0, @@ -153,14 +153,14 @@ impl TerminalState { agent: Option<Agent>, fallback_state: AgentState, visible_blocker: bool, - visible_idle: bool, + _ignored_screen_idle: bool, process_exited: bool, ) -> Option<EffectiveStateChange> { self.set_detected_state_with_screen_signals_at( agent, fallback_state, visible_blocker, - visible_idle, + false, false, process_exited, Instant::now(), @@ -173,8 +173,8 @@ impl TerminalState { agent: Option<Agent>, fallback_state: AgentState, visible_blocker: bool, - visible_idle: bool, - visible_working: bool, + _ignored_screen_idle: bool, + _visible_working: bool, process_exited: bool, now: Instant, ) -> TerminalStateMutation { @@ -184,11 +184,36 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { + if self + .hook_authority + .as_ref() + .and_then(|authority| crate::detect::parse_agent_label(&authority.agent_label)) + == agent + { + self.detected_agent = agent; + } + return TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session + != self.current_session_identity_for_persistence(), + }; + } self.detected_agent = agent; + if !process_exited { + self.clear_full_lifecycle_hook_suppression_for_detected_agent( + previous_detected_agent, + agent, + ); + } self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; - self.fallback_visible_idle = visible_idle && fallback_state == AgentState::Idle; - self.fallback_visible_working = visible_working && fallback_state == AgentState::Working; self.fallback_observed_at = Some(now); if process_exited && self.hook_authority_not_newer_than(now) @@ -196,8 +221,8 @@ impl TerminalState { crate::detect::parse_agent_label(&authority.agent_label) == agent }) { + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; } if self.hook_authority_not_newer_than(now) && (self.hook_authority_conflicts_with_detected_agent(agent) @@ -208,8 +233,8 @@ impl TerminalState { == previous_detected_agent }))) { + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; } let detected_agent_changed_or_disappeared = previous_detected_agent.is_some() && agent != previous_detected_agent; @@ -220,7 +245,6 @@ impl TerminalState { { self.persisted_agent_session = None; } - self.update_stale_hook_idle_window(now); TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -302,6 +326,9 @@ impl TerminalState { seq: Option<u64>, now: Instant, ) -> Option<TerminalStateMutation> { + if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { + return None; + } if !self.accept_hook_report(&source, seq) { return None; } @@ -318,6 +345,16 @@ impl TerminalState { self.conflicting_current_session_ref(&source, &agent_label, &session_ref) .unwrap_or(session_ref) }); + if self.live_full_lifecycle_hook_authority_conflicts_with_session( + &source, + &agent_label, + &session_ref, + ) { + return None; + } + if session_ref.is_some() { + self.suppressed_full_lifecycle_hook_reports.remove(&source); + } self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { source, @@ -328,7 +365,6 @@ impl TerminalState { reported_at: now, session_ref, }); - self.stale_hook_idle_since = None; let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -365,6 +401,16 @@ impl TerminalState { }) } + fn should_ignore_detected_state_under_full_lifecycle_hook( + &self, + detected_agent: Option<Agent>, + process_exited: bool, + ) -> bool { + self.live_full_lifecycle_hook_authority() + && !process_exited + && !self.hook_authority_conflicts_with_detected_agent(detected_agent) + } + fn persisted_agent_session_conflicts_with_detected_agent( &self, detected_agent: Option<Agent>, @@ -397,6 +443,113 @@ impl TerminalState { .is_some_and(|session| session.source == source && session.agent == agent) } + fn suppress_current_full_lifecycle_hook_authority(&mut self) { + if let Some((source, agent_label)) = self + .hook_authority + .as_ref() + .filter(|authority| { + crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) + }) + .map(|authority| (authority.source.clone(), authority.agent_label.clone())) + { + let session_ref = self + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.clone()); + self.suppressed_full_lifecycle_hook_reports.insert( + source, + SuppressedFullLifecycleHookReport { + agent_label, + session_ref, + }, + ); + } + } + + fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) { + if crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) { + self.suppressed_full_lifecycle_hook_reports.insert( + source.to_string(), + SuppressedFullLifecycleHookReport { + agent_label: agent_label.to_string(), + session_ref: self + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.clone()), + }, + ); + } + } + + fn full_lifecycle_hook_report_is_suppressed( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + if !crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) { + return false; + } + self.suppressed_full_lifecycle_hook_reports + .get(source) + .is_some_and(|suppressed| { + if suppressed.agent_label != agent_label { + return false; + } + match (&suppressed.session_ref, session_ref) { + (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => true, + } + }) + } + + fn live_full_lifecycle_hook_authority_conflicts_with_session( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + let Some(authority) = self.hook_authority.as_ref() else { + return false; + }; + if !crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) { + return false; + } + if authority.source != source || authority.agent_label != agent_label { + return false; + } + authority + .session_ref + .as_ref() + .zip(session_ref.as_ref()) + .is_some_and(|(current, incoming)| current != incoming) + } + + fn clear_full_lifecycle_hook_suppression_for_detected_agent( + &mut self, + previous_detected_agent: Option<Agent>, + detected_agent: Option<Agent>, + ) { + let Some(detected_agent) = detected_agent else { + return; + }; + if previous_detected_agent == Some(detected_agent) { + return; + } + self.suppressed_full_lifecycle_hook_reports + .retain(|_, agent_label| { + crate::detect::parse_agent_label(&agent_label.agent_label) != Some(detected_agent) + }); + } + fn current_session_identity_for_persistence( &self, ) -> Option<( @@ -551,8 +704,8 @@ impl TerminalState { if !should_clear { return None; } + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -605,14 +758,12 @@ impl TerminalState { let previous_state = self.state; let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_session = self.current_session_identity_for_persistence(); + self.suppress_full_lifecycle_hook_report(source, agent_label); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; - self.fallback_visible_idle = false; - self.fallback_visible_working = false; self.fallback_observed_at = None; self.hook_authority = None; - self.stale_hook_idle_since = None; self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -641,6 +792,9 @@ impl TerminalState { } fn visible_blocker_overrides_hook(&self) -> bool { + if self.live_full_lifecycle_hook_authority() { + return false; + } self.fallback_visible_blocker && self.fallback_not_older_than_hook() && self.hook_authority.as_ref().is_some_and(|authority| { @@ -650,61 +804,13 @@ impl TerminalState { }) } - fn visible_working_overrides_hook(&self) -> bool { - self.fallback_visible_working - && self.visible_working_is_fresh_enough_for_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Idle || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }) - } - - fn visible_working_is_fresh_enough_for_hook(&self) -> bool { - self.fallback_not_older_than_hook() - || self - .fallback_observed_at - .zip( - self.hook_authority - .as_ref() - .map(|authority| authority.reported_at), - ) - .is_some_and(|(observed_at, reported_at)| { - reported_at >= observed_at - && reported_at.duration_since(observed_at) < SCREEN_WORKING_HOLD - }) - } - - fn visible_idle_stales_hook(&self, now: Instant) -> bool { - self.stale_hook_idle_since - .is_some_and(|since| now.duration_since(since) >= STALE_HOOK_IDLE_GRACE) - } - - fn visible_idle_masks_hook_custom_status(&self, state: AgentState, now: Instant) -> bool { - self.fallback_visible_idle - && self.fallback_not_older_than_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Working || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }) - && (state == AgentState::Idle || self.visible_idle_stales_hook(now)) - } - - fn update_stale_hook_idle_window(&mut self, now: Instant) { - let visible_idle_stales_hook = self.fallback_visible_idle - && self.fallback_not_older_than_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Working || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }); - - if visible_idle_stales_hook { - self.stale_hook_idle_since.get_or_insert(now); - } else { - self.stale_hook_idle_since = None; - } + fn live_full_lifecycle_hook_authority(&self) -> bool { + self.hook_authority.as_ref().is_some_and(|authority| { + crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) + }) } pub fn set_manual_label(&mut self, label: String) { @@ -729,13 +835,11 @@ impl TerminalState { self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; - self.fallback_visible_idle = false; - self.fallback_visible_working = false; self.fallback_observed_at = None; - self.stale_hook_idle_since = None; self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.suppressed_full_lifecycle_hook_reports.clear(); self.state = AgentState::Unknown; self.launch_argv = None; self.respawn_shell_on_exit = false; @@ -772,10 +876,6 @@ impl TerminalState { ) -> Option<EffectiveStateChange> { let state = if self.visible_blocker_overrides_hook() { AgentState::Blocked - } else if self.visible_working_overrides_hook() { - AgentState::Working - } else if self.visible_idle_stales_hook(now) { - AgentState::Idle } else { self.hook_authority .as_ref() @@ -809,55 +909,8 @@ impl TerminalState { } } -pub(crate) fn stabilize_agent_state( - agent: Option<Agent>, - previous: AgentState, - raw: AgentState, - now: std::time::Instant, - last_screen_working_at: &mut Option<std::time::Instant>, -) -> AgentState { - if !matches!(agent, Some(Agent::Claude | Agent::Droid)) { - return raw; - } - - match raw { - AgentState::Working => { - *last_screen_working_at = Some(now); - AgentState::Working - } - AgentState::Blocked => AgentState::Blocked, - AgentState::Idle if previous == AgentState::Working => { - if last_screen_working_at - .is_some_and(|last_working| now.duration_since(last_working) < SCREEN_WORKING_HOLD) - { - AgentState::Working - } else { - AgentState::Idle - } - } - _ => raw, - } -} - -pub(crate) fn stabilize_agent_detection( - agent: Option<Agent>, - previous: AgentState, - detection: crate::detect::AgentDetection, - process_exited: bool, - now: std::time::Instant, - last_screen_working_at: &mut Option<std::time::Instant>, -) -> AgentState { - if process_exited { - return detection.state; - } - - stabilize_agent_state( - agent, - previous, - detection.state, - now, - last_screen_working_at, - ) +pub(crate) fn stabilize_agent_detection(detection: crate::detect::AgentDetection) -> AgentState { + detection.state } #[cfg(test)] @@ -878,184 +931,367 @@ mod tests { } #[test] - fn claude_working_is_sticky_for_short_gap() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn stabilization_uses_raw_policy_state() { + let detection = AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }; - let working = stabilize_agent_state( - Some(Agent::Claude), - AgentState::Idle, - AgentState::Working, - now, - &mut last_working, - ); - assert_eq!(working, AgentState::Working); + assert_eq!(stabilize_agent_detection(detection), AgentState::Idle); + } - let still_working = stabilize_agent_state( - Some(Agent::Claude), + #[test] + fn hook_authority_overrides_fallback_for_same_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentState::Idle, - now + std::time::Duration::from_millis(400), - &mut last_working, + None, + None, ); - assert_eq!(still_working, AgentState::Working); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn claude_transitions_to_idle_after_hold_expires() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_state( - Some(Agent::Claude), + fn hook_authority_can_override_with_unknown_agent_label() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:custom".into(), + "custom-agent".into(), AgentState::Working, - AgentState::Idle, - now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), - &mut last_working, + None, + None, ); - assert_eq!(state, AgentState::Idle); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); + assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn process_exit_idle_bypasses_claude_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_detection( - Some(Agent::Claude), + fn omp_hook_authority_works_without_detected_agent_variant() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:omp".into(), + "omp".into(), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: false, - visible_working: false, - }, + None, + None, + ); + + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.effective_agent_label(), Some("omp")); + assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.state, AgentState::Working); + + let change = terminal.set_detected_state_with_visible_blocker( + None, + AgentState::Blocked, true, - now + std::time::Duration::from_millis(100), - &mut last_working, + false, + false, ); - assert_eq!(state, AgentState::Idle); + assert_eq!(terminal.fallback_state, AgentState::Unknown); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); } #[test] - fn visible_idle_does_not_bypass_claude_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn session_only_report_does_not_create_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); - let state = stabilize_agent_detection( - Some(Agent::Claude), + let mutation = terminal.set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + crate::agent_resume::AgentSessionRef::id("codex-session"), + Some(1), + ); + + assert!(mutation.is_some()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: true, - visible_working: false, - }, false, - now + std::time::Duration::from_millis(100), - &mut last_working, + false, + false, + false, + Instant::now(), ); - assert_eq!(state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn droid_working_is_sticky_for_short_gap() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn process_exit_clears_matching_full_lifecycle_hook_authority() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(10), + now, + ); - let working = stabilize_agent_state( - Some(Agent::Droid), + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Working + ); + + let stale = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - now, - &mut last_working, + None, + None, + None, + Some(9), + now + Duration::from_millis(2), ); - assert_eq!(working, AgentState::Working); - let still_working = stabilize_agent_state( - Some(Agent::Droid), + assert!(stale.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + } + + #[test] + fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + None, + Some(20), + now, + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, - now + std::time::Duration::from_millis(400), - &mut last_working, + false, + true, + false, + true, + now + Duration::from_millis(1), ); - assert_eq!(still_working, AgentState::Working); + let late = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + None, + Some(21), + now + Duration::from_millis(2), + ); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); } #[test] - fn droid_transitions_to_idle_after_hold_expires() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_state( - Some(Agent::Droid), + fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority() + { + let now = Instant::now(); + let mut terminal = test_terminal(); + let session_path = test_session_path("pi.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(20), + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, - now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), - &mut last_working, + false, + true, + false, + true, + now + Duration::from_millis(1), ); - assert_eq!(state, AgentState::Idle); + let late = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(21), + ); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); } #[test] - fn process_exit_idle_bypasses_droid_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); - let state = stabilize_agent_detection( - Some(Agent::Droid), + terminal.release_agent("herdr:pi", "pi", Some(21)); + let late = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: false, - visible_working: false, - }, - true, - now + std::time::Duration::from_millis(100), - &mut last_working, + None, + Some(22), ); - assert_eq!(state, AgentState::Idle); + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] - fn droid_blocked_bypasses_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() { + let mut terminal = test_terminal(); + let session_path = test_session_path("pi.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(20), + ); - let state = stabilize_agent_state( - Some(Agent::Droid), + terminal.release_agent("herdr:pi", "pi", Some(21)); + let late = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentState::Blocked, - now + std::time::Duration::from_millis(100), - &mut last_working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path), + Some(22), ); - assert_eq!(state, AgentState::Blocked); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] - fn codex_states_are_unchanged_by_screen_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); - let state = stabilize_agent_state( - Some(Agent::Codex), + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), + Some(22), + ); + + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), + Some(20), + ); + + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Idle, - now, - &mut last_working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), + Some(21), + ); + + assert!(mutation.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .map(|session_ref| session_ref.value.as_str()), + Some(test_session_path("one.jsonl").as_str()) ); - assert_eq!(state, AgentState::Idle); } #[test] - fn hook_authority_overrides_fallback_for_same_agent() { + fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() { + let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( @@ -1063,30 +1299,57 @@ mod tests { "pi".into(), AgentState::Working, None, + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now, + ); + let fresh = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, None, + Some(22), ); - assert_eq!(terminal.detected_agent, Some(Agent::Pi)); - assert_eq!(terminal.fallback_state, AgentState::Idle); - assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] - fn hook_authority_can_override_with_unknown_agent_label() { + fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( - "herdr:custom".into(), - "custom-agent".into(), + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); + + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, None, None, + crate::agent_resume::AgentSessionRef::id("fresh-session"), + Some(22), ); - assert_eq!(terminal.detected_agent, Some(Agent::Pi)); - assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); - assert_eq!(terminal.effective_known_agent(), None); + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } @@ -1116,6 +1379,31 @@ mod tests { } #[test] + fn visible_blocker_does_not_override_full_lifecycle_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Pi), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); + } + + #[test] fn weak_blocked_fallback_does_not_override_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); @@ -1214,18 +1502,10 @@ mod tests { } #[test] - fn visible_idle_waits_before_overriding_claude_hook_working() { + fn fallback_idle_does_not_override_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Working, - false, - false, - true, - false, - now, - ); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( "herdr:claude".into(), "claude".into(), @@ -1237,50 +1517,32 @@ mod tests { now, ); - let waiting = terminal.set_detected_state_with_screen_signals_at( + terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Idle, false, true, false, false, - now + Duration::from_millis(500), + now + Duration::from_secs(10), ); - assert!(waiting.effective_state_change.is_none()); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.effective_custom_status().as_deref(), Some("thinking") ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), - ); - - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Working - ); } #[test] - fn fresh_hook_working_resets_visible_idle_stale_window() { + fn fallback_idle_does_not_override_full_lifecycle_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); + terminal.set_detected_state(Some(Agent::Kimi), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + "herdr:kimi".into(), + "kimi".into(), AgentState::Working, None, Some("thinking".into()), @@ -1289,47 +1551,62 @@ mod tests { now, ); terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), + Some(Agent::Kimi), AgentState::Idle, false, true, false, false, - now + Duration::from_millis(500), + now + Duration::from_secs(10), + ); + + assert_eq!(terminal.fallback_state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal.effective_custom_status().as_deref(), + Some("thinking") ); + } + #[test] + fn visible_working_does_not_override_hook_idle_for_same_agent() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( "herdr:claude".into(), "claude".into(), - AgentState::Working, + AgentState::Idle, None, - Some("thinking".into()), None, - Some(1), - now + Duration::from_millis(800), + None, + None, + now, ); + let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), - AgentState::Idle, + AgentState::Working, false, - true, false, + true, false, - now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), + now + Duration::from_millis(1), ); + assert_eq!(terminal.fallback_state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Idle); assert!(change.effective_state_change.is_none()); - assert_eq!(terminal.state, AgentState::Working); } #[test] - fn visible_working_overrides_hook_idle_for_same_agent() { + fn visible_working_does_not_override_full_lifecycle_hook_idle() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + "herdr:hermes".into(), + "hermes".into(), AgentState::Idle, None, None, @@ -1339,7 +1616,7 @@ mod tests { ); let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), + Some(Agent::Hermes), AgentState::Working, false, false, @@ -1348,44 +1625,44 @@ mod tests { now + Duration::from_millis(1), ); - assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Idle - ); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Idle); + assert!(change.effective_state_change.is_none()); } #[test] - fn recent_visible_working_holds_against_newer_claude_hook_idle() { + fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Working, - false, - false, - true, - false, - now, - ); - - let change = terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + terminal.set_detected_state(Some(Agent::Qodercli), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:qodercli".into(), + "qodercli".into(), AgentState::Idle, None, None, None, None, - now + Duration::from_millis(100), + now, ); - assert!(change.unwrap().effective_state_change.is_none()); - assert_eq!(terminal.state, AgentState::Working); + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Qodercli), + AgentState::Working, + false, + false, + false, + false, + now + Duration::from_millis(1), + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Idle); + assert!(change.effective_state_change.is_none()); } #[test] - fn old_visible_working_does_not_hold_against_newer_claude_hook_idle() { + fn visible_working_does_not_hold_against_newer_claude_hook_idle() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state_with_screen_signals_at( @@ -1406,7 +1683,7 @@ mod tests { None, None, None, - now + SCREEN_WORKING_HOLD + Duration::from_millis(1), + now + Duration::from_millis(100), ); assert_eq!(terminal.state, AgentState::Idle); @@ -1421,7 +1698,7 @@ mod tests { } #[test] - fn refreshed_visible_working_overrides_newer_hook_blocked() { + fn refreshed_visible_working_does_not_override_newer_hook_blocked() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state_with_screen_signals_at( @@ -1441,7 +1718,7 @@ mod tests { Some("permission".into()), None, None, - now + SCREEN_WORKING_HOLD + Duration::from_millis(1), + now + Duration::from_millis(1201), ); assert_eq!(terminal.state, AgentState::Blocked); @@ -1453,71 +1730,20 @@ mod tests { false, true, false, - now + SCREEN_WORKING_HOLD + Duration::from_millis(800), - ); - - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Blocked - ); - } - - #[test] - fn visible_idle_waits_before_overriding_claude_hook_blocked() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), - AgentState::Blocked, - None, - Some("permission".into()), - None, - None, - now, - ); - - let waiting = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500), + now + Duration::from_millis(2000), ); - assert!(waiting.effective_state_change.is_none()); - assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!( terminal.effective_custom_status().as_deref(), Some("permission") ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), - ); - - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Blocked - ); + assert!(change.effective_state_change.is_none()); } #[test] - fn visible_idle_does_not_override_other_agent_hook_working() { + fn fallback_idle_does_not_override_other_agent_hook_working() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_hook_authority( @@ -1620,6 +1846,39 @@ mod tests { } #[test] + fn full_lifecycle_hook_authority_ignores_detected_agent_clear_without_process_exit() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + None, + now, + ); + + let change = terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(1), + ); + + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.effective_state_change.is_none()); + } + + #[test] fn detected_agent_clear_clears_matching_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); @@ -1721,7 +1980,6 @@ mod tests { ); assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.stale_hook_idle_since.is_none()); } #[test] @@ -2202,7 +2460,7 @@ mod tests { } #[test] - fn detected_agent_disappearance_clears_matching_hook_session_ref() { + fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); terminal.set_hook_authority_with_session_ref( @@ -2217,10 +2475,10 @@ mod tests { let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); - assert!(mutation.session_ref_changed); - assert!(terminal.hook_authority.is_none()); + assert!(!mutation.session_ref_changed); + assert!(terminal.hook_authority.is_some()); assert!(terminal.persisted_agent_session.is_none()); - assert_eq!(terminal.effective_agent_label(), None); + assert_eq!(terminal.effective_agent_label(), Some("hermes")); } #[test] |
