use std::collections::HashMap; use std::path::PathBuf; #[cfg(test)] use std::time::Duration; use std::time::Instant; // 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}; use crate::terminal::TerminalId; #[path = "metadata.rs"] mod metadata; pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookAuthority { pub source: String, pub agent_label: String, pub state: AgentState, pub message: Option, pub custom_status: Option, pub reported_at: Instant, pub session_ref: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct SuppressedFullLifecycleHookReport { agent_label: String, session_ref: Option, observed_at: Instant, reason: FullLifecycleHookSuppressionReason, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FullLifecycleHookSuppressionReason { HookClear, ProcessExit, } #[derive(Debug, Clone, PartialEq, Eq)] struct StaleFullLifecycleHookSession { agent_label: String, session_ref: crate::agent_resume::AgentSessionRef, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option, pub previous_known_agent: Option, pub previous_state: AgentState, pub previous_presentation: EffectivePresentation, pub agent_label: Option, pub known_agent: Option, pub state: AgentState, pub presentation: EffectivePresentation, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TerminalStateMutation { pub effective_state_change: Option, pub session_ref_changed: bool, } /// Pure state for a server-owned terminal. /// /// During the migration this is still one-to-one with a pane-backed PTY, but /// pane/view state no longer owns terminal identity, cwd, labels, or agent /// metadata. pub struct TerminalState { pub id: TerminalId, pub cwd: PathBuf, pub detected_agent: Option, pub fallback_state: AgentState, fallback_visible_blocker: bool, fallback_observed_at: Option, pub hook_authority: Option, pub agent_metadata: HashMap, pub persisted_agent_session: Option, pub manual_label: Option, pub agent_name: Option, hook_report_sequences: HashMap, suppressed_full_lifecycle_hook_reports: HashMap, stale_full_lifecycle_hook_sessions: HashMap>, metadata_report_sequences: HashMap, pub state: AgentState, pub last_agent_state_change_seq: Option, pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, pub pending_agent_resume_plan: Option, } impl TerminalState { pub fn new(id: TerminalId, cwd: PathBuf) -> Self { Self { id, cwd, detected_agent: None, fallback_state: AgentState::Unknown, fallback_visible_blocker: false, fallback_observed_at: 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(), stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, pending_agent_resume_plan: None, } } pub fn with_launch_argv(mut self, argv: Vec) -> Self { self.launch_argv = Some(argv); self } pub fn with_respawn_shell_on_exit(mut self) -> Self { self.respawn_shell_on_exit = true; self } pub fn with_pending_agent_resume_plan( mut self, plan: crate::agent_resume::AgentResumePlan, ) -> Self { self.pending_agent_resume_plan = Some(plan); self } #[cfg(test)] pub fn set_detected_state( &mut self, agent: Option, fallback_state: AgentState, ) -> Option { self.set_detected_state_with_visible_blocker(agent, fallback_state, false, false, false) } #[cfg(test)] pub fn set_detected_state_with_mutation( &mut self, agent: Option, fallback_state: AgentState, ) -> TerminalStateMutation { self.set_detected_state_with_screen_signals_at( agent, fallback_state, false, false, false, false, Instant::now(), ) } #[cfg(test)] pub fn set_detected_state_with_visible_blocker( &mut self, agent: Option, fallback_state: AgentState, visible_blocker: bool, _ignored_screen_idle: bool, process_exited: bool, ) -> Option { self.set_detected_state_with_screen_signals_at( agent, fallback_state, visible_blocker, false, false, process_exited, Instant::now(), ) .effective_state_change } pub fn set_detected_state_with_screen_signals_at( &mut self, agent: Option, fallback_state: AgentState, visible_blocker: bool, _visible_idle: bool, _visible_working: bool, process_exited: bool, now: Instant, ) -> TerminalStateMutation { let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; 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(), }; } if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) { 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_observed_at = Some(now); if process_exited && self.hook_authority_not_newer_than(now) && self.hook_authority.as_ref().is_some_and(|authority| { crate::detect::parse_agent_label(&authority.agent_label) == agent }) { let cleared_source = self .hook_authority .as_ref() .map(|authority| authority.source.clone()); self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::ProcessExit, ); if let Some(source) = cleared_source { self.hook_report_sequences.remove(&source); } self.hook_authority = None; } if process_exited && self .persisted_agent_session .as_ref() .is_some_and(|session| crate::detect::parse_agent_label(&session.agent) == agent) { self.persisted_agent_session = None; } if self.hook_authority_not_newer_than(now) && (self.hook_authority_conflicts_with_detected_agent(agent) || (previous_detected_agent.is_some() && agent != previous_detected_agent && self.hook_authority.as_ref().is_some_and(|authority| { crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent }))) { let durable_session = self.hook_authority.as_ref().and_then(|authority| { authority.session_ref.as_ref().map(|session_ref| { crate::agent_resume::PersistedAgentSession { source: authority.source.clone(), agent: authority.agent_label.clone(), session_ref: session_ref.clone(), } }) }); self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); self.hook_authority = None; self.persisted_agent_session = durable_session; } 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(), } } #[cfg(test)] pub fn set_hook_authority( &mut self, source: String, agent_label: String, state: AgentState, message: Option, seq: Option, ) -> Option { self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) } #[cfg(test)] pub fn set_hook_authority_with_custom_status( &mut self, source: String, agent_label: String, state: AgentState, message: Option, custom_status: Option, seq: Option, ) -> Option { self.set_hook_authority_with_custom_status_at( source, agent_label, state, message, custom_status, None, seq, Instant::now(), ) .and_then(|mutation| mutation.effective_state_change) } pub fn set_hook_authority_with_session_ref( &mut self, source: String, agent_label: String, state: AgentState, message: Option, custom_status: Option, session_ref: Option, seq: Option, ) -> Option { self.set_hook_authority_with_custom_status_at( source, agent_label, state, message, custom_status, session_ref, seq, Instant::now(), ) } pub fn set_hook_authority_with_custom_status_at( &mut self, source: String, agent_label: String, state: AgentState, message: Option, custom_status: Option, session_ref: Option, seq: Option, now: Instant, ) -> Option { if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { return None; } if self.full_lifecycle_hook_report_matches_stale_session( &source, &agent_label, &session_ref, ) { return None; } let reanchor_sequence = self.full_lifecycle_hook_report_has_fresh_session_after_suppression( &source, &agent_label, &session_ref, ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session( &source, &agent_label, &session_ref, ); if self.known_agent_label_conflicts_with_detected_agent(&agent_label) || self.current_session_owner_conflicts(&source, &agent_label) { return None; } let session_ref = session_ref.map(|session_ref| { self.conflicting_same_owner_session_ref(&source, &agent_label, &session_ref, None) .unwrap_or(session_ref) }); if self.live_full_lifecycle_hook_authority_conflicts_with_session( &source, &agent_label, &session_ref, ) { return None; } if reanchor_sequence { self.hook_report_sequences.remove(&source); } if !self.accept_hook_report(&source, seq) { return None; } let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); 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(); if session_ref.is_some() { if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) { if let Some(suppressed_ref) = suppressed.session_ref { self.remember_stale_full_lifecycle_hook_session( source.clone(), suppressed.agent_label, suppressed_ref, ); } } } self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { source, agent_label, state, message, custom_status, reported_at: now, session_ref, }); let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, previous_known_agent, previous_state, previous_presentation, now, ), session_ref_changed: previous_session != current_session, }) } fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool { self.hook_authority .as_ref() .is_none_or(|authority| authority.reported_at <= observed_at) } fn fallback_not_older_than_hook(&self) -> bool { self.hook_authority.as_ref().is_none_or(|authority| { self.fallback_observed_at .is_some_and(|observed_at| authority.reported_at <= observed_at) }) } fn hook_authority_conflicts_with_detected_agent(&self, detected_agent: Option) -> bool { let Some(detected_agent) = detected_agent else { return false; }; self.hook_authority.as_ref().is_some_and(|authority| { crate::detect::parse_agent_label(&authority.agent_label) .is_some_and(|hook_agent| hook_agent != detected_agent) }) } fn should_ignore_detected_state_under_full_lifecycle_hook( &self, detected_agent: Option, 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_matches(&self, source: &str, agent: &str) -> bool { self.persisted_agent_session .as_ref() .is_some_and(|session| session.source == source && session.agent == agent) } fn suppress_current_full_lifecycle_hook_authority( &mut self, reason: FullLifecycleHookSuppressionReason, ) { if let Some((source, agent_label, session_ref)) = self.hook_authority.as_ref().and_then(|authority| { crate::detect::full_lifecycle_hook_authority( &authority.source, &authority.agent_label, ) .then(|| { ( authority.source.clone(), authority.agent_label.clone(), authority.session_ref.clone(), ) }) }) { self.suppress_full_lifecycle_hook_report_with_session_ref( source, agent_label, session_ref, reason, ); } } fn suppress_full_lifecycle_hook_report( &mut self, source: &str, agent_label: &str, reason: FullLifecycleHookSuppressionReason, ) { if crate::detect::full_lifecycle_hook_authority(source, agent_label) { let session_ref = self .hook_authority .as_ref() .and_then(|authority| authority.session_ref.clone()); self.suppress_full_lifecycle_hook_report_with_session_ref( source.to_string(), agent_label.to_string(), session_ref, reason, ); } } fn suppress_full_lifecycle_hook_report_with_session_ref( &mut self, source: String, agent_label: String, session_ref: Option, reason: FullLifecycleHookSuppressionReason, ) { self.suppressed_full_lifecycle_hook_reports.insert( source, SuppressedFullLifecycleHookReport { agent_label, session_ref, observed_at: Instant::now(), reason, }, ); } fn full_lifecycle_hook_report_is_suppressed( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { if !crate::detect::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; } if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit { return true; } 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 full_lifecycle_hook_report_has_fresh_session_after_suppression( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { return false; } self.suppressed_full_lifecycle_hook_reports .get(source) .is_some_and(|suppressed| { suppressed.agent_label == agent_label && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit && matches!( (&suppressed.session_ref, session_ref), (Some(suppressed_ref), Some(incoming_ref)) if incoming_ref != suppressed_ref ) }) } fn full_lifecycle_hook_report_matches_stale_session( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { return false; } self.stale_full_lifecycle_hook_sessions .get(source) .is_some_and(|stale_sessions| { session_ref.as_ref().is_some_and(|incoming_ref| { stale_sessions.iter().any(|stale| { stale.agent_label == agent_label && incoming_ref == &stale.session_ref }) }) }) } fn full_lifecycle_hook_report_has_fresh_session_after_stale_session( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { return false; } self.stale_full_lifecycle_hook_sessions .get(source) .is_some_and(|stale_sessions| { stale_sessions .iter() .any(|stale| stale.agent_label == agent_label) && session_ref.as_ref().is_some_and(|incoming_ref| { stale_sessions.iter().all(|stale| { stale.agent_label != agent_label || incoming_ref != &stale.session_ref }) }) }) } fn live_full_lifecycle_hook_authority_conflicts_with_session( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { let Some(authority) = self.hook_authority.as_ref() else { return false; }; if !crate::detect::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, detected_agent: Option, ) { let Some(detected_agent) = detected_agent else { return; }; if previous_detected_agent == Some(detected_agent) { return; } let detected_label = crate::detect::agent_label(detected_agent); let mut stale_sessions = Vec::new(); self.suppressed_full_lifecycle_hook_reports .retain(|source, suppressed| { let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent); if should_clear { if let Some(session_ref) = suppressed.session_ref.clone() { stale_sessions.push(( source.clone(), StaleFullLifecycleHookSession { agent_label: suppressed.agent_label.clone(), session_ref, }, )); } } !should_clear }); for (source, stale_session) in stale_sessions { self.remember_stale_full_lifecycle_hook_session( source, stale_session.agent_label, stale_session.session_ref, ); } self.hook_report_sequences.retain(|source, _| { !crate::detect::full_lifecycle_hook_authority(source, detected_label) }); } fn remember_stale_full_lifecycle_hook_session( &mut self, source: String, agent_label: String, session_ref: crate::agent_resume::AgentSessionRef, ) { let stale_session = StaleFullLifecycleHookSession { agent_label, session_ref, }; let source_stale_sessions = self .stale_full_lifecycle_hook_sessions .entry(source) .or_default(); if !source_stale_sessions .iter() .any(|existing| existing == &stale_session) { source_stale_sessions.push(stale_session); } } fn detected_state_observed_before_release_suppression( &self, detected_agent: Option, observed_at: Instant, ) -> bool { let Some(detected_agent) = detected_agent else { return false; }; self.suppressed_full_lifecycle_hook_reports .values() .any(|suppressed| { crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent) && observed_at <= suppressed.observed_at }) } fn current_session_identity_for_persistence( &self, ) -> Option<( String, String, crate::agent_resume::AgentSessionRefKind, String, )> { if let Some(authority) = self.hook_authority.as_ref() { if let Some(session_ref) = authority.session_ref.as_ref() { return Some(( authority.source.clone(), authority.agent_label.clone(), session_ref.kind, session_ref.value.clone(), )); } } self.persisted_agent_session.as_ref().map(|session| { ( session.source.clone(), session.agent.clone(), session.session_ref.kind, session.session_ref.value.clone(), ) }) } fn current_session_owner_conflicts(&self, source: &str, agent_label: &str) -> bool { self.current_session_identity_for_persistence().is_some_and( |(current_source, current_agent, _, _)| { current_source != source || current_agent != agent_label }, ) } fn conflicting_same_owner_session_ref( &self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, session_start_source: Option<&str>, ) -> Option { self.current_session_identity_for_persistence().and_then( |(current_source, current_agent, current_kind, current_value)| { (current_source == source && current_agent == agent_label && current_kind == crate::agent_resume::AgentSessionRefKind::Id && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id && current_value != session_ref.value && !Self::session_start_source_allows_session_replacement( source, agent_label, session_start_source, )) .then_some(crate::agent_resume::AgentSessionRef { kind: current_kind, value: current_value, }) }, ) } fn session_start_source_allows_session_replacement( source: &str, agent_label: &str, session_start_source: Option<&str>, ) -> bool { matches!( (source, agent_label, session_start_source), ( "herdr:claude", "claude", Some("clear" | "resume" | "compact") ) | ( "herdr:codex", "codex", Some("startup" | "clear" | "resume" | "compact") ) | ("herdr:opencode", "opencode", Some("new")) ) } pub fn set_persisted_agent_session( &mut self, session: crate::agent_resume::PersistedAgentSession, ) { self.persisted_agent_session = Some(session); } pub fn set_agent_session_ref( &mut self, source: String, agent_label: String, session_ref: Option, seq: Option, ) -> Option { self.set_agent_session_ref_for_session_start(source, agent_label, session_ref, seq, None) } pub fn set_agent_session_ref_for_session_start( &mut self, source: String, agent_label: String, session_ref: Option, seq: Option, session_start_source: Option, ) -> Option { let session_ref = session_ref?; if !self.accept_hook_report(&source, seq) { return None; } if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } if self.current_session_owner_conflicts(&source, &agent_label) || self .conflicting_same_owner_session_ref( &source, &agent_label, &session_ref, session_start_source.as_deref(), ) .is_some() { return None; } let previous_session = self.current_session_identity_for_persistence(); self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { source, agent: agent_label, session_ref, }); let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { effective_state_change: None, session_ref_changed: previous_session != current_session, }) } fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { let Some(detected_agent) = self.detected_agent else { return false; }; crate::detect::parse_agent_label(agent_label) .is_some_and(|hook_agent| hook_agent != detected_agent) } fn accept_hook_report(&mut self, source: &str, seq: Option) -> bool { let Some(seq) = seq else { return !self.hook_report_sequences.contains_key(source); }; if self .hook_report_sequences .get(source) .is_some_and(|last_seq| seq <= *last_seq) { return false; } self.hook_report_sequences.insert(source.to_string(), seq); true } #[cfg(test)] pub fn clear_hook_authority( &mut self, source: Option<&str>, seq: Option, ) -> Option { self.clear_hook_authority_with_mutation(source, seq) .and_then(|mutation| mutation.effective_state_change) } pub fn clear_hook_authority_with_mutation( &mut self, source: Option<&str>, seq: Option, ) -> Option { let sequence_source = source.map(str::to_string).or_else(|| { self.hook_authority .as_ref() .map(|authority| authority.source.clone()) }); if let Some(source) = sequence_source.as_deref() { if !self.accept_hook_report(source, seq) { return None; } } let now = Instant::now(); let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); 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(); let should_clear = self .hook_authority .as_ref() .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); if !should_clear { return None; } self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); self.hook_authority = None; self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, previous_known_agent, previous_state, previous_presentation, now, ), session_ref_changed: previous_session.is_some(), }) } #[cfg(test)] pub fn release_agent( &mut self, source: &str, agent_label: &str, seq: Option, ) -> Option { self.release_agent_with_mutation(source, agent_label, seq) .and_then(|mutation| mutation.effective_state_change) } pub fn release_agent_with_mutation( &mut self, source: &str, agent_label: &str, seq: Option, ) -> Option { if !self.accept_hook_report(source, seq) { return None; } if self.hook_authority.as_ref().is_some_and(|authority| { authority.agent_label != agent_label || authority.source != source }) { return None; } let matches_current_agent = self.effective_agent_label() == Some(agent_label); let matches_persisted_session = self.persisted_agent_session_matches(source, agent_label); if !matches_current_agent && !matches_persisted_session { return None; } let preserve_foreign_persisted_session = self .persisted_agent_session .as_ref() .is_some_and(|session| session.source != source || session.agent != agent_label); let now = Instant::now(); let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); 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, FullLifecycleHookSuppressionReason::HookClear, ); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; self.fallback_observed_at = None; self.hook_authority = None; if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, previous_known_agent, previous_state, previous_presentation, now, ), session_ref_changed: previous_session != current_session, }) } pub fn effective_agent_label(&self) -> Option<&str> { self.hook_authority .as_ref() .map(|authority| authority.agent_label.as_str()) .or_else(|| self.detected_agent.map(crate::detect::agent_label)) } pub fn effective_known_agent(&self) -> Option { if let Some(authority) = &self.hook_authority { return crate::detect::parse_agent_label(&authority.agent_label); } self.detected_agent } pub fn full_lifecycle_hook_authority_active(&self) -> bool { self.live_full_lifecycle_hook_authority() } 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| { authority.state != AgentState::Blocked && crate::detect::parse_agent_label(&authority.agent_label) == self.detected_agent }) } fn live_full_lifecycle_hook_authority(&self) -> bool { self.hook_authority.as_ref().is_some_and(|authority| { crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) }) } pub fn set_manual_label(&mut self, label: String) { let label = label.trim().to_string(); self.manual_label = (!label.is_empty()).then_some(label); } pub fn clear_manual_label(&mut self) { self.manual_label = None; } pub fn set_agent_name(&mut self, name: String) { let name = name.trim().to_string(); self.agent_name = (!name.is_empty()).then_some(name); } pub fn clear_agent_name(&mut self) { self.agent_name = None; } pub fn clear_agent_runtime_identity_after_respawn(&mut self) { self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; self.fallback_observed_at = None; self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); self.suppressed_full_lifecycle_hook_reports.clear(); self.stale_full_lifecycle_hook_sessions.clear(); self.state = AgentState::Unknown; self.last_agent_state_change_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; self.pending_agent_resume_plan = None; self.clear_agent_name(); } pub fn is_agent_terminal(&self) -> bool { self.agent_name.is_some() || self.effective_agent_label().is_some() || self.launch_argv.is_some() } pub fn border_label(&self, show_agent_labels: bool) -> Option { self.effective_title().or_else(|| { self.manual_label.clone().or_else(|| { show_agent_labels .then(|| { self.effective_display_agent() .or_else(|| self.effective_agent_label().map(str::to_string)) }) .flatten() }) }) } fn recompute_effective_state( &mut self, previous_agent_label: Option, previous_known_agent: Option, previous_state: AgentState, previous_presentation: EffectivePresentation, now: Instant, ) -> Option { let state = if self.visible_blocker_overrides_hook() { AgentState::Blocked } else { self.hook_authority .as_ref() .map(|authority| authority.state) .unwrap_or(self.fallback_state) }; let agent_label = self.effective_agent_label().map(str::to_string); let known_agent = self.effective_known_agent(); let presentation = self.effective_presentation_for_state_at(state, now); self.clear_expiry_pending_for_hidden_metadata(); if previous_agent_label == agent_label && previous_state == state && previous_presentation == presentation { return None; } self.state = state; Some(EffectiveStateChange { previous_agent_label, previous_known_agent, previous_state, previous_presentation, agent_label, known_agent, state, presentation, }) } } pub(crate) fn stabilize_agent_detection(detection: crate::detect::AgentDetection) -> AgentState { detection.state } #[cfg(test)] mod tests { use super::*; use crate::detect::AgentDetection; fn test_terminal() -> TerminalState { TerminalState::new(TerminalId::alloc(), "/tmp".into()) } fn test_session_path(name: &str) -> String { std::env::current_dir() .unwrap() .join(name) .display() .to_string() } #[test] fn stabilization_uses_raw_policy_state() { let detection = AgentDetection { state: AgentState::Idle, skip_state_update: false, visible_idle: false, visible_blocker: false, visible_working: false, }; assert_eq!(stabilize_agent_detection(detection), AgentState::Idle); } #[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, None, None, ); 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 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, None, None, ); 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 omp_hook_authority_overrides_detected_fallback() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); terminal.set_hook_authority( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, ); assert_eq!(terminal.detected_agent, Some(Agent::Omp)); assert_eq!(terminal.effective_agent_label(), Some("omp")); assert_eq!(terminal.effective_known_agent(), Some(Agent::Omp)); assert_eq!(terminal.state, AgentState::Working); let change = terminal.set_detected_state_with_visible_blocker( Some(Agent::Omp), AgentState::Blocked, true, false, false, ); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert!(change.is_none()); } #[test] fn session_only_report_does_not_create_hook_authority() { for (agent, source, label, session_id) in [ (Agent::Codex, "herdr:codex", "codex", "codex-session"), (Agent::Devin, "herdr:devin", "devin", "devin-session"), ] { let mut terminal = test_terminal(); terminal.set_detected_state(Some(agent), AgentState::Idle); let mutation = terminal.set_agent_session_ref( source.into(), label.into(), crate::agent_resume::AgentSessionRef::id(session_id), Some(1), ); assert!(mutation.is_some()); assert!(terminal.hook_authority.is_none()); assert!(!terminal.full_lifecycle_hook_authority_active()); assert_eq!(terminal.state, AgentState::Idle); terminal.set_detected_state_with_screen_signals_at( Some(agent), AgentState::Working, false, false, false, false, Instant::now(), ); assert_eq!(terminal.state, AgentState::Working); } } #[test] 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 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, None, None, None, Some(9), now + Duration::from_millis(2), ); 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, false, true, false, true, now + Duration::from_millis(1), ); 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 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, false, true, false, true, now + Duration::from_millis(1), ); 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 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), ); terminal.release_agent("herdr:pi", "pi", Some(21)); let late = terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(22), ); assert!(late.is_none()); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.state, AgentState::Unknown); } #[test] 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), ); terminal.release_agent("herdr:pi", "pi", Some(21)); let late = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_path), Some(22), ); assert!(late.is_none()); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.state, AgentState::Unknown); } #[test] 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 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 changed_session_ref_reanchors_hook_sequence_after_release() { 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(1000), ); terminal.release_agent("herdr:pi", "pi", Some(3000)); 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(1500), ); assert!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn stale_session_suppression_survives_multiple_release_generations() { let mut terminal = test_terminal(); let session_a = test_session_path("release-generation-a.jsonl"); let session_b = test_session_path("release-generation-b.jsonl"); let session_c = test_session_path("release-generation-c.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_a.clone()), Some(1000), ); terminal.release_agent("herdr:pi", "pi", Some(2000)); let generation_b = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_b), Some(1500), ); assert!(generation_b.is_some()); terminal.release_agent("herdr:pi", "pi", Some(3000)); let late_generation_a = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(2500), ); let generation_c = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_c), Some(2500), ); assert!(late_generation_a.is_none()); assert!(generation_c.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, 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()) ); } #[test] fn fresh_detected_process_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:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); terminal.release_agent("herdr:pi", "pi", Some(21)); let now = Instant::now(); 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!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() { 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(1000), ); let process_exit_seen_at = Instant::now() + Duration::from_millis(1); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, process_exit_seen_at, ); let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, fresh_process_seen_at, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, fresh_process_seen_at + Duration::from_millis(1), ); let fresh = terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(500), ); assert!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() { let mut terminal = test_terminal(); let old_session = test_session_path("old-process-exit.jsonl"); let new_session = test_session_path("new-process-exit.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(old_session.clone()), Some(1000), ); let process_exit_seen_at = Instant::now() + Duration::from_secs(1); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, process_exit_seen_at, ); let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, fresh_process_seen_at, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, fresh_process_seen_at + Duration::from_millis(1), ); let late_old = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some("late".into()), crate::agent_resume::AgentSessionRef::path(old_session), Some(500), ); let fresh_new = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), ); assert!(late_old.is_none()); assert!(fresh_new.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn different_session_after_process_exit_waits_for_fresh_process_evidence() { let mut terminal = test_terminal(); let old_session = test_session_path("old-before-process-exit.jsonl"); let new_session = test_session_path("new-after-process-exit.jsonl"); let now = Instant::now(); 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, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, now + Duration::from_millis(1), ); let early_new = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(500), now + Duration::from_millis(2), ); assert!(early_new.is_none()); assert!(terminal.hook_authority.is_none()); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, now + Duration::from_millis(3), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, now + Duration::from_millis(4), ); let fresh_new = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), now + Duration::from_millis(5), ); assert!(fresh_new.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn missing_session_after_process_exit_waits_for_fresh_process_evidence() { let mut terminal = test_terminal(); let old_session = test_session_path("old-before-nosession-process-exit.jsonl"); let now = Instant::now(); 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, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, now + Duration::from_millis(1), ); let early_without_session = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, None, Some(500), now + Duration::from_millis(2), ); assert!(early_without_session.is_none()); assert!(terminal.hook_authority.is_none()); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, now + Duration::from_millis(3), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, now + Duration::from_millis(4), ); let fresh_without_session = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, None, Some(500), now + Duration::from_millis(5), ); assert!(fresh_without_session.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn stale_session_suppression_survives_multiple_process_generations() { let mut terminal = test_terminal(); let session_a = test_session_path("generation-a.jsonl"); let session_b = test_session_path("generation-b.jsonl"); let session_c = test_session_path("generation-c.jsonl"); let now = Instant::now(); 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, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(1000), now, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, now + Duration::from_millis(1), ); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, now + Duration::from_millis(2), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, now + Duration::from_millis(3), ); let generation_b = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_b), Some(500), now + Duration::from_millis(4), ); assert!(generation_b.is_some()); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, true, now + Duration::from_millis(5), ); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, now + Duration::from_millis(6), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Unknown, false, false, false, false, now + Duration::from_millis(7), ); let late_generation_a = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(250), now + Duration::from_millis(8), ); let generation_c = terminal.set_hook_authority_with_custom_status_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_c), Some(250), now + Duration::from_millis(9), ); assert!(late_generation_a.is_none()); assert!(generation_c.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn release_suppression_ignores_same_agent_idle_publish() { let now = Instant::now(); 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), ); terminal.release_agent("herdr:pi", "pi", Some(21)); let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, true, false, false, now, ); let late = terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(22), ); assert!(change.effective_state_change.is_none()); assert!(late.is_none()); assert_eq!(terminal.detected_agent, None); assert_eq!(terminal.state, AgentState::Unknown); } #[test] 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: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!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } // Regression for #614: a same-pane restart must reacquire lifecycle authority. #[test] fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { let mut terminal = test_terminal(); terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(20), ); terminal.release_agent("herdr:omp", "omp", Some(21)); // A late report from the released run keeps its old session ref and stays // suppressed, so a just-exited omp cannot resurrect the pane. let stale = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(22), ); assert!(stale.is_none()); assert!(terminal.hook_authority.is_none()); // A fresh omp run carries a new session ref and reacquires authority. let fresh = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-new"), Some(23), ); assert!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn omp_reacquires_full_lifecycle_hook_after_process_exit_with_fresh_process_and_session_ref() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(1000), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Omp), AgentState::Idle, false, true, false, true, now + Duration::from_millis(1), ); let stale = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(500), ); assert!(stale.is_none()); assert!(terminal.hook_authority.is_none()); terminal.set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, now + Duration::from_millis(2), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Omp), AgentState::Unknown, false, false, false, false, now + Duration::from_millis(3), ); let fresh = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("omp-new"), Some(500), ); assert!(fresh.is_some()); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn visible_blocker_overrides_non_blocked_hook_for_same_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, ); let change = terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Blocked, true, false, false, ); assert_eq!(terminal.fallback_state, AgentState::Blocked); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!(change.unwrap().previous_state, AgentState::Working); } #[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); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, ); let change = terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Blocked, false, false, false, ); assert_eq!(terminal.fallback_state, AgentState::Blocked); assert_eq!(terminal.state, AgentState::Working); assert!(change.is_none()); } #[test] fn hook_blocked_wins_over_visible_blocker() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Blocked, None, None, ); terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Blocked, true, false, false, ); assert_eq!(terminal.state, AgentState::Blocked); assert!(terminal.hook_authority.is_some()); } #[test] fn visible_blocker_does_not_override_different_agent_hook() { let mut terminal = test_terminal(); terminal.set_detected_state(None, AgentState::Unknown); terminal.set_hook_authority( "custom:agent".into(), "custom-agent".into(), AgentState::Working, None, None, ); terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Blocked, true, false, false, ); assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); assert_eq!(terminal.state, AgentState::Working); } #[test] fn visible_blocker_suppresses_stale_hook_custom_status() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.set_hook_authority_with_custom_status( "herdr:codex".into(), "codex".into(), AgentState::Working, None, Some("planning".into()), None, ); terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Blocked, true, false, false, ); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!(terminal.effective_custom_status(), None); } #[test] fn fallback_idle_does_not_override_hook_working() { 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::Working, None, Some("thinking".into()), None, None, now, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Idle, false, true, false, false, now + Duration::from_secs(10), ); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.effective_custom_status().as_deref(), Some("thinking") ); } #[test] 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::OpenCode), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, Some("thinking".into()), None, None, now, ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::OpenCode), AgentState::Idle, false, true, false, false, 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::Idle, None, None, None, None, now, ); let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Working, false, false, true, false, 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()); } #[test] 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::Hermes), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( "herdr:hermes".into(), "hermes".into(), AgentState::Idle, None, None, None, None, now, ); let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Hermes), AgentState::Working, false, false, true, 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 detected_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( "herdr:kilo".into(), "kilo".into(), AgentState::Idle, None, None, None, None, now, ); let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Kilo), 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 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( 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(), AgentState::Idle, None, None, None, None, now + Duration::from_millis(100), ); assert_eq!(terminal.state, AgentState::Idle); assert_eq!( change .unwrap() .effective_state_change .unwrap() .previous_state, AgentState::Working ); } #[test] 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( Some(Agent::Codex), AgentState::Working, false, false, true, false, now, ); terminal.set_hook_authority_with_custom_status_at( "herdr:codex".into(), "codex".into(), AgentState::Blocked, None, Some("permission".into()), None, None, now + Duration::from_millis(1201), ); assert_eq!(terminal.state, AgentState::Blocked); let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Codex), AgentState::Working, false, false, true, false, now + Duration::from_millis(2000), ); assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!( terminal.effective_custom_status().as_deref(), Some("permission") ); assert!(change.effective_state_change.is_none()); } #[test] 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( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, ); let change = terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Idle, false, true, false, ); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert!(change.is_none()); } #[test] fn known_hook_authority_does_not_override_different_detected_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); let change = terminal.set_hook_authority( "herdr:claude".into(), "claude".into(), AgentState::Blocked, None, None, ); assert!(change.is_none()); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::Grok)); assert_eq!(terminal.effective_agent_label(), Some("grok")); assert_eq!(terminal.state, AgentState::Working); } #[test] fn detected_agent_clears_conflicting_known_hook_authority() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:claude".into(), "claude".into(), AgentState::Blocked, None, None, ); terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::Grok)); assert_eq!(terminal.effective_agent_label(), Some("grok")); assert_eq!(terminal.state, AgentState::Working); } #[test] fn border_label_prefers_manual_label_over_agent_label() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); assert_eq!(terminal.border_label(false), None); assert_eq!(terminal.border_label(true).as_deref(), Some("claude")); terminal.set_manual_label(" reviewer ".into()); assert_eq!(terminal.border_label(false).as_deref(), Some("reviewer")); assert_eq!(terminal.border_label(true).as_deref(), Some("reviewer")); terminal.set_manual_label(" ".into()); assert_eq!(terminal.border_label(true).as_deref(), Some("claude")); terminal.set_manual_label("reviewer".into()); terminal.clear_manual_label(); assert_eq!(terminal.border_label(true).as_deref(), Some("claude")); } #[test] fn hook_authority_survives_unrelated_detected_agent_clear() { 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, None, None, ); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.detected_agent, None); assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); assert_eq!(terminal.state, AgentState::Working); } #[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::Cursor), AgentState::Idle); terminal.set_hook_authority( "herdr:cursor".into(), "cursor".into(), AgentState::Idle, None, None, ); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, None); assert_eq!(terminal.fallback_state, AgentState::Unknown); assert_eq!(terminal.effective_agent_label(), None); assert_eq!(terminal.state, AgentState::Unknown); } #[test] fn detected_agent_clear_clears_matching_working_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, ); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, None); assert_eq!(terminal.effective_agent_label(), None); assert_eq!(terminal.state, AgentState::Unknown); } #[test] fn process_exit_clears_matching_hook_authority_before_reporting_idle() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, ); terminal.set_detected_state_with_visible_blocker( Some(Agent::Codex), AgentState::Idle, false, false, true, ); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::Codex)); assert_eq!(terminal.effective_agent_label(), Some("codex")); assert_eq!(terminal.state, AgentState::Idle); } #[test] fn stale_visible_screen_signal_does_not_override_newer_hook_authority() { let mut terminal = test_terminal(); let observed = Instant::now(); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Working, false, false, true, false, observed, ); terminal.set_hook_authority_with_custom_status_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, None, None, Some(1), observed + Duration::from_secs(1), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Idle, false, true, false, false, observed, ); assert_eq!(terminal.state, AgentState::Working); } #[test] fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() { let mut terminal = test_terminal(); let observed = Instant::now(); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Codex), AgentState::Working, false, false, false, false, observed, ); terminal.set_hook_authority_with_custom_status_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, None, Some(1), observed, ); terminal.set_hook_authority_with_custom_status_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, Some("new turn".into()), None, Some(2), observed + Duration::from_secs(1), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Codex), AgentState::Idle, false, false, false, true, observed, ); let authority = terminal.hook_authority.as_ref().expect("hook authority"); assert_eq!(authority.custom_status.as_deref(), Some("new turn")); assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.effective_agent_label(), Some("codex")); } #[test] fn detected_agent_change_clears_previous_matching_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.set_hook_authority( "herdr:codex".into(), "codex".into(), AgentState::Idle, None, None, ); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::OpenCode)); assert_eq!(terminal.effective_agent_label(), Some("opencode")); assert_eq!(terminal.state, AgentState::Working); } #[test] fn release_agent_clears_identity_immediately() { 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, ); terminal.release_agent("herdr:pi", "pi", None); assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, None); assert_eq!(terminal.fallback_state, AgentState::Unknown); assert_eq!(terminal.state, AgentState::Unknown); } #[test] fn stale_hook_report_sequence_is_ignored_for_same_source() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); let change = terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, Some(19), ); assert!(change.is_none()); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.hook_authority.as_ref().unwrap().state, AgentState::Working ); } #[test] fn accepted_hook_report_stores_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); let mutation = 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), ) .expect("accepted report"); assert!(mutation.session_ref_changed); assert_eq!( terminal .hook_authority .as_ref() .and_then(|authority| authority.session_ref.as_ref()) .map(|session_ref| (&session_ref.kind, session_ref.value.as_str())), Some(( &crate::agent_resume::AgentSessionRefKind::Path, session_path.as_str() )) ); } #[test] fn stale_hook_report_cannot_overwrite_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); let new_session_path = test_session_path("new.jsonl"); 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 mutation = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(new_session_path), Some(19), ); assert!(mutation.is_none()); assert_eq!( terminal .hook_authority .as_ref() .and_then(|authority| authority.session_ref.as_ref()) .map(|session_ref| session_ref.value.as_str()), Some(session_path.as_str()) ); } #[test] fn accepted_hook_report_without_session_ref_clears_previous_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); let mutation = terminal .set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, None, Some(21), ) .expect("accepted report"); assert!(mutation.session_ref_changed); assert!(mutation.effective_state_change.is_none()); assert!(terminal .hook_authority .as_ref() .unwrap() .session_ref .is_none()); } #[test] fn accepted_hook_report_marks_changed_when_same_owner_session_identity_changes() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")) .unwrap(), }); let mutation = 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(20), ) .expect("accepted report"); assert!(mutation.session_ref_changed); } #[test] fn different_same_agent_session_ref_is_ignored_until_current_session_clears() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal.set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("nested-session"), Some(21), ); assert!(mutation.is_none()); assert_eq!( terminal.hook_report_sequences.get("herdr:claude"), Some(&21) ); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("claude-session") ); } #[test] fn claude_startup_session_ref_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal.set_agent_session_ref_for_session_start( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("nested-session"), Some(21), Some("startup".into()), ); assert!(mutation.is_none()); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("claude-session") ); } #[test] fn claude_lifecycle_session_ref_replaces_existing_session_ref() { for session_start_source in ["clear", "resume", "compact"] { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ) .expect("initial session should be accepted"); let next_session = format!("{session_start_source}-session"); let mutation = terminal .set_agent_session_ref_for_session_start( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id(&next_session), Some(21), Some(session_start_source.into()), ) .unwrap_or_else(|| panic!("{session_start_source} should replace the session")); assert!( mutation.session_ref_changed, "{session_start_source} should mark the session changed" ); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some(next_session.as_str()), "{session_start_source} should store the replacement session" ); } } #[test] fn codex_lifecycle_session_ref_replaces_existing_session_ref() { for session_start_source in ["startup", "clear", "resume", "compact"] { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:codex".into(), "codex".into(), crate::agent_resume::AgentSessionRef::id("codex-session"), Some(20), ) .expect("initial session should be accepted"); let next_session = format!("codex-{session_start_source}-session"); let mutation = terminal .set_agent_session_ref_for_session_start( "herdr:codex".into(), "codex".into(), crate::agent_resume::AgentSessionRef::id(&next_session), Some(21), Some(session_start_source.into()), ) .unwrap_or_else(|| panic!("{session_start_source} should replace the session")); assert!(mutation.session_ref_changed); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some(next_session.as_str()) ); } } #[test] fn opencode_new_session_ref_replaces_existing_session_ref() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-new"), Some(21), Some("new".into()), ) .expect("new should replace the session"); assert!(mutation.session_ref_changed); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("opencode-new") ); } #[test] fn opencode_session_ref_without_start_source_does_not_replace_existing() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), ) .expect("initial session should be accepted"); // session.updated reports carry no session_start_source, so a different // id must not displace the established session (cross-talk guard). let mutation = terminal.set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-other"), Some(21), None, ); assert!(mutation.is_none()); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("opencode-old") ); } #[test] fn different_owner_session_ref_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:droid".into(), "droid".into(), crate::agent_resume::AgentSessionRef::id("droid-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal.set_agent_session_ref_for_session_start( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(21), Some("resume".into()), ); assert!(mutation.is_none()); assert_eq!( terminal.persisted_agent_session.as_ref().map(|session| ( session.source.as_str(), session.agent.as_str(), session.session_ref.value.as_str() )), Some(("herdr:droid", "droid", "droid-session")) ); } #[test] fn different_owner_full_lifecycle_hook_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:droid".into(), "droid".into(), crate::agent_resume::AgentSessionRef::id("droid-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"), Some(21), ); assert!(mutation.is_none()); assert!(terminal.hook_authority.is_none()); assert_eq!( terminal.persisted_agent_session.as_ref().map(|session| ( session.source.as_str(), session.agent.as_str(), session.session_ref.value.as_str() )), Some(("herdr:droid", "droid", "droid-session")) ); } #[test] fn repeated_same_agent_session_ref_is_accepted_without_session_change() { let mut terminal = test_terminal(); terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(21), ) .expect("same session should be accepted"); assert!(!mutation.session_ref_changed); } #[test] fn hook_authority_preserves_current_session_ref_when_incoming_ref_differs() { let mut terminal = test_terminal(); terminal .set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal .set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Blocked, Some("needs approval".into()), None, crate::agent_resume::AgentSessionRef::id("nested-session"), Some(21), ) .expect("state update should still be accepted"); assert!(!mutation.session_ref_changed); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!( terminal .hook_authority .as_ref() .and_then(|authority| authority.session_ref.as_ref()) .map(|session_ref| session_ref.value.as_str()), Some("opencode-session") ); } #[test] fn detected_agent_clear_does_not_clear_current_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); terminal .set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ) .expect("initial session should be accepted"); let clear = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); assert!(!clear.session_ref_changed); let mutation = terminal.set_agent_session_ref( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("new-session"), Some(21), ); assert!(mutation.is_none()); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("claude-session") ); } #[test] fn clearing_hook_authority_clears_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); let mutation = terminal .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21)) .expect("accepted clear"); assert!(mutation.session_ref_changed); assert!(terminal.hook_authority.is_none()); } #[test] fn release_agent_clears_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); let mutation = terminal .release_agent_with_mutation("herdr:pi", "pi", Some(21)) .expect("accepted release"); assert!(mutation.session_ref_changed); assert!(terminal.hook_authority.is_none()); } #[test] fn release_agent_clears_matching_restored_session_ref_before_detection() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:hermes".into(), agent: "hermes".into(), session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), }); let mutation = terminal .release_agent_with_mutation("herdr:hermes", "hermes", Some(21)) .expect("accepted release"); assert!(mutation.session_ref_changed); assert!(mutation.effective_state_change.is_none()); assert!(terminal.persisted_agent_session.is_none()); } #[test] fn release_agent_preserves_foreign_persisted_session_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:claude".into(), agent: "claude".into(), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session").unwrap(), }); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); let mutation = terminal .release_agent_with_mutation("herdr:pi", "pi", Some(21)) .expect("visible agent release should be accepted"); assert!(!mutation.session_ref_changed); assert_eq!( terminal.persisted_agent_session.as_ref().map(|session| ( session.source.as_str(), session.agent.as_str(), session.session_ref.value.as_str() )), Some(("herdr:claude", "claude", "claude-session")) ); } #[test] fn process_exit_clears_matching_persisted_session_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")) .unwrap(), }); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); let mutation = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, false, false, true, std::time::Instant::now(), ); assert!(mutation.session_ref_changed); assert!(terminal.persisted_agent_session.is_none()); } #[test] fn process_exit_preserves_foreign_persisted_session_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:claude".into(), agent: "claude".into(), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session").unwrap(), }); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); let mutation = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, false, false, true, std::time::Instant::now(), ); assert!(!mutation.session_ref_changed); assert_eq!( terminal .persisted_agent_session .as_ref() .map(|session| session.session_ref.value.as_str()), Some("claude-session") ); } #[test] fn respawn_cleanup_resets_restored_agent_status() { let mut terminal = test_terminal(); terminal.respawn_shell_on_exit = true; terminal.set_agent_name("codex".into()); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:codex".into(), agent: "codex".into(), session_ref: crate::agent_resume::AgentSessionRef::id("codex-session").unwrap(), }); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.clear_agent_runtime_identity_after_respawn(); assert_eq!(terminal.state, AgentState::Unknown); assert!(terminal.detected_agent.is_none()); assert!(terminal.agent_name.is_none()); assert!(terminal.persisted_agent_session.is_none()); assert!(!terminal.respawn_shell_on_exit); } #[test] fn detected_conflict_clears_live_hook_but_preserves_session_ref() { let mut terminal = test_terminal(); terminal.set_hook_authority_with_session_ref( "herdr:claude".into(), "claude".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ); let mutation = terminal.set_detected_state_with_mutation(Some(Agent::Grok), AgentState::Idle); assert!(!mutation.session_ref_changed); assert!(terminal.hook_authority.is_none()); assert_eq!( terminal.persisted_agent_session.as_ref().map(|session| ( session.source.as_str(), session.agent.as_str(), session.session_ref.value.as_str() )), Some(("herdr:claude", "claude", "claude-session")) ); } #[test] 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( "herdr:hermes".into(), "hermes".into(), AgentState::Working, None, None, crate::agent_resume::AgentSessionRef::id("hermes-session"), Some(20), ); let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); assert!(!mutation.session_ref_changed); assert!(terminal.hook_authority.is_some()); assert!(terminal.persisted_agent_session.is_none()); assert_eq!(terminal.effective_agent_label(), Some("hermes")); } #[test] fn detected_agent_disappearance_preserves_matching_persisted_session_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:opencode".into(), agent: "opencode".into(), session_ref: crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), }); let first = terminal.set_detected_state_with_mutation(Some(Agent::OpenCode), AgentState::Idle); assert!(!first.session_ref_changed); assert!(terminal.persisted_agent_session.is_some()); let second = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); assert!(!second.session_ref_changed); assert!(terminal.persisted_agent_session.is_some()); } #[test] fn initial_unknown_detection_preserves_restored_session_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:hermes".into(), agent: "hermes".into(), session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), }); let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); assert!(!mutation.session_ref_changed); assert!(terminal.persisted_agent_session.is_some()); } #[test] fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); let change = terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, None, ); assert!(change.is_none()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn stale_release_sequence_is_ignored_for_same_source() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); let change = terminal.release_agent("herdr:pi", "pi", Some(19)); assert!(change.is_none()); assert_eq!(terminal.state, AgentState::Working); assert!(terminal.hook_authority.is_some()); } #[test] fn stale_clear_all_sequence_is_checked_against_current_authority_source() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); let change = terminal.clear_hook_authority(None, Some(19)); assert!(change.is_none()); assert_eq!(terminal.state, AgentState::Working); assert!(terminal.hook_authority.is_some()); } #[test] fn same_sequence_from_different_sources_is_independent() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(20), ); terminal.set_hook_authority( "custom:pi".into(), "pi".into(), AgentState::Idle, None, Some(19), ); assert_eq!(terminal.state, AgentState::Idle); assert_eq!( terminal.hook_authority.as_ref().unwrap().source, "custom:pi" ); } }