use std::collections::HashMap; use std::path::PathBuf; use std::time::{Duration, 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 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, Copy, PartialEq, Eq)] enum ManagedAgentPhase { Pending { ready_after: Option, deadline: Instant, observed_expected: bool, }, Active, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ManagedAgent { kind: Agent, phase: ManagedAgentPhase, } #[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, Copy, PartialEq, Eq, Default)] pub(crate) struct TerminalTitleChange { pub(crate) raw_changed: bool, pub(crate) stripped_changed: bool, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TerminalStateMutation { pub effective_state_change: Option, pub session_ref_changed: bool, pub agent_released: bool, } #[derive(Debug, Clone, PartialEq, Eq)] struct AgentNameOwner { agent_label: String, session_ref: Option, } /// 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 metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option, pub terminal_title: Option, pub manual_label: Option, pub agent_name: Option, agent_name_owner: Option, managed_agent: Option, hook_report_sequences: HashMap, suppressed_full_lifecycle_hook_reports: HashMap, stale_full_lifecycle_hook_sessions: HashMap>, metadata_report_sequences: HashMap, metadata_token_sequence_sources: std::collections::HashSet, pub state: AgentState, pub last_agent_state_change_seq: Option, pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, recent_agent_process_exit_at: Option, 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(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, terminal_title: None, manual_label: None, agent_name: None, agent_name_owner: None, managed_agent: 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(), metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, recent_agent_process_exit_at: None, pending_agent_resume_plan: None, } } pub(crate) fn terminal_title_stripped(&self) -> Option { self.terminal_title .as_deref() .and_then(super::stripped_terminal_title) } pub(crate) fn set_terminal_title(&mut self, title: Option) -> TerminalTitleChange { if self.terminal_title == title { return TerminalTitleChange::default(); } let previous_stripped = self.terminal_title_stripped(); self.terminal_title = title; let stripped_changed = previous_stripped != self.terminal_title_stripped(); if stripped_changed { self.revision = self.revision.wrapping_add(1); } TerminalTitleChange { raw_changed: true, stripped_changed, } } 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 } #[cfg(any(windows, test))] pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool { self.recent_agent_process_exit_at .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age) } 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(); let agent_released = process_exited && self.hook_authority_not_newer_than(now) && (previous_agent_label.is_some() || self.agent_name.is_some()); 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(), agent_released: false, }; } 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(), agent_released: false, }; } self.detected_agent = agent; if let Some(agent) = agent { let agent_label = crate::detect::agent_label(agent); self.reconcile_agent_name_owner(agent_label, None); } 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 && agent.is_some() { self.recent_agent_process_exit_at = Some(now); } else if agent.is_some() { self.recent_agent_process_exit_at = None; } 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; } if agent_released { self.clear_agent_name(); } 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(), agent_released, } } #[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_at( source, agent_label, state, message, 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, session_ref: Option, seq: Option, ) -> Option { self.set_hook_authority_at( source, agent_label, state, message, session_ref, seq, Instant::now(), ) } pub fn set_hook_authority_at( &mut self, source: String, agent_label: String, state: AgentState, message: 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) { return None; } let owner_conflicts = self.current_session_owner_conflicts(&source, &agent_label); let foreground_takeover_allowed = owner_conflicts && self.foreground_agent_confirms_hook_authority_takeover( &source, &agent_label, &session_ref, ); if owner_conflicts && !foreground_takeover_allowed { return None; } let session_ref = session_ref.map(|session_ref| { if self.lifecycle_hook_report_replaces_persisted_session( &source, &agent_label, &session_ref, ) { session_ref } else { 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(); self.reconcile_agent_name_owner(&agent_label, session_ref.as_ref()); if foreground_takeover_allowed { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); } 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, 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, agent_released: false, }) } 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 same_owner_full_lifecycle_hook_authority_session_ref( &self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, ) -> Option { let authority = self.hook_authority.as_ref()?; if !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) || authority.source != source || authority.agent_label != agent_label { return None; } authority .session_ref .as_ref() .filter(|current| *current != session_ref) .cloned() } 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 forget_stale_full_lifecycle_hook_session( &mut self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, ) { let remove_source = self .stale_full_lifecycle_hook_sessions .get_mut(source) .is_some_and(|stale_sessions| { stale_sessions.retain(|stale| { stale.agent_label != agent_label || &stale.session_ref != session_ref }); stale_sessions.is_empty() }); if remove_source { self.stale_full_lifecycle_hook_sessions.remove(source); } } 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 lifecycle_hook_report_replaces_persisted_session( &self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, ) -> bool { self.hook_authority.is_none() && (source, agent_label) == ("herdr:mastracode", "mastracode") && self .persisted_agent_session .as_ref() .is_some_and(|session| { session.source == source && session.agent == agent_label && session.session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id && session.session_ref.value != session_ref.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")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", "omp", Some("startup" | "new" | "resume" | "fork") ) ) } fn session_start_source_is_recognized(session_start_source: Option<&str>) -> bool { matches!( session_start_source, Some("startup" | "clear" | "resume" | "compact" | "new" | "fork") ) } 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; } let session_replacement_allowed = Self::session_start_source_allows_session_replacement( &source, &agent_label, session_start_source.as_deref(), ); let owner_conflicts = self.current_session_owner_conflicts(&source, &agent_label); let foreground_takeover_allowed = owner_conflicts && self.foreground_agent_confirms_different_owner_takeover( &source, &agent_label, &session_ref, session_start_source.as_deref(), ); if owner_conflicts && !foreground_takeover_allowed { return None; } if self .conflicting_same_owner_session_ref( &source, &agent_label, &session_ref, session_start_source.as_deref(), ) .is_some() { return None; } let replaced_hook_session = self.same_owner_full_lifecycle_hook_authority_session_ref( &source, &agent_label, &session_ref, ); if replaced_hook_session.is_some() && !session_replacement_allowed { 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(); if session_replacement_allowed || foreground_takeover_allowed { self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref); } if let Some(replaced_hook_session) = replaced_hook_session { self.remember_stale_full_lifecycle_hook_session( source.clone(), agent_label.clone(), replaced_hook_session, ); self.hook_authority = None; } else if foreground_takeover_allowed { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); self.hook_authority = None; } self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); 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: self.recompute_effective_state( previous_agent_label, previous_known_agent, previous_state, previous_presentation, now, ), session_ref_changed: previous_session != current_session, agent_released: false, }) } 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 foreground_agent_confirms_different_owner_takeover( &self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, session_start_source: Option<&str>, ) -> bool { Self::session_start_source_is_recognized(session_start_source) && self.foreground_agent_confirms_session_owner(source, agent_label, session_ref) } fn foreground_agent_confirms_hook_authority_takeover( &self, source: &str, agent_label: &str, session_ref: &Option, ) -> bool { session_ref.as_ref().is_some_and(|session_ref| { self.foreground_agent_confirms_session_owner(source, agent_label, session_ref) }) } fn foreground_agent_confirms_session_owner( &self, source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, ) -> bool { let Some(detected_agent) = self.detected_agent else { return false; }; crate::detect::parse_agent_label(agent_label) == Some(detected_agent) && crate::agent_resume::plan(source, agent_label, session_ref).is_some() } 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(), agent_released: false, }) } #[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; self.clear_agent_name(); 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, agent_released: true, }) } 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(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { let agent_label = self.effective_agent_label().map(str::to_string); let known_agent = self.effective_known_agent(); let state = self.state; let presentation = self.effective_presentation_for_state_at(state, now); EffectiveStateChange { previous_agent_label: agent_label.clone(), previous_known_agent: known_agent, previous_state: state, previous_presentation: presentation.clone(), agent_label, known_agent, state, presentation, } } 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) { self.agent_name = (!name.is_empty()).then_some(name); self.agent_name_owner = self.agent_name.as_ref().and_then(|_| { self.hook_authority .as_ref() .map(|authority| AgentNameOwner { agent_label: authority.agent_label.clone(), session_ref: authority.session_ref.clone(), }) .or_else(|| { self.persisted_agent_session .as_ref() .map(|session| AgentNameOwner { agent_label: session.agent.clone(), session_ref: Some(session.session_ref.clone()), }) }) .or_else(|| { self.effective_agent_label() .map(|agent_label| AgentNameOwner { agent_label: agent_label.to_string(), session_ref: None, }) }) }); } pub fn begin_managed_agent( &mut self, name: String, kind: Agent, now: Instant, settle_delay: Duration, timeout: Duration, ) { self.set_agent_name(name); self.agent_name_owner = Some(AgentNameOwner { agent_label: crate::detect::agent_label(kind).to_string(), session_ref: None, }); self.managed_agent = Some(ManagedAgent { kind, phase: ManagedAgentPhase::Pending { ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), deadline: now.checked_add(timeout).unwrap_or(now), observed_expected: false, }, }); } pub fn managed_agent_launch_pending(&self) -> bool { self.managed_agent .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) } pub fn managed_agent_interactive_ready(&self) -> bool { self.managed_agent .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) } pub fn managed_agent_kind(&self) -> Option { self.managed_agent.map(|managed| managed.kind) } pub fn next_managed_agent_deadline(&self) -> Option { let ManagedAgentPhase::Pending { ready_after, deadline, .. } = self.managed_agent?.phase else { return None; }; Some(ready_after.unwrap_or(deadline).min(deadline)) } pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool { let Some(managed) = self.managed_agent else { return false; }; let known_agent = self.effective_known_agent(); let observed_expected = match managed.phase { ManagedAgentPhase::Pending { observed_expected, .. } => observed_expected || known_agent == Some(managed.kind), ManagedAgentPhase::Active => false, }; let clear = process_exited || known_agent.is_some_and(|agent| agent != managed.kind) || matches!(managed.phase, ManagedAgentPhase::Pending { .. }) && observed_expected && known_agent.is_none(); if clear { self.clear_agent_name(); return true; } if let ManagedAgentPhase::Pending { ready_after, deadline, observed_expected: previous_observed_expected, } = managed.phase { if now >= deadline { self.clear_agent_name(); return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { if known_agent == Some(managed.kind) && matches!(self.state, AgentState::Idle | AgentState::Blocked) { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, }); return true; } if ready_after.is_some() { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Pending { ready_after: None, deadline, observed_expected, }, }); return true; } } if observed_expected != previous_observed_expected { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Pending { ready_after, deadline, observed_expected, }, }); return true; } } false } pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { self.set_agent_name(name); self.agent_name_owner = Some(AgentNameOwner { agent_label: crate::detect::agent_label(kind).to_string(), session_ref: None, }); self.managed_agent = Some(ManagedAgent { kind, phase: ManagedAgentPhase::Active, }); } pub fn clear_agent_name(&mut self) { self.agent_name = None; self.agent_name_owner = None; self.managed_agent = 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.recent_agent_process_exit_at = None; 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() } fn reconcile_agent_name_owner( &mut self, agent_label: &str, session_ref: Option<&crate::agent_resume::AgentSessionRef>, ) { if self.agent_name.is_none() { return; } if self.managed_agent.is_some_and(|managed| { crate::detect::parse_agent_label(agent_label) == Some(managed.kind) }) { return; } match self.agent_name_owner.as_mut() { Some(owner) if owner.agent_label != agent_label || owner .session_ref .as_ref() .zip(session_ref) .is_some_and(|(current, incoming)| current != incoming) => { self.agent_name = None; self.agent_name_owner = None; } Some(owner) if owner.session_ref.is_none() && session_ref.is_some() => { owner.session_ref = session_ref.cloned(); } None => { self.agent_name_owner = Some(AgentNameOwner { agent_label: agent_label.to_string(), session_ref: session_ref.cloned(), }) } _ => {} } } 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 managed_agent_activates_only_after_matching_settled_detection() { let mut terminal = test_terminal(); let now = Instant::now(); terminal.begin_managed_agent( "reviewer".into(), Agent::Pi, now, Duration::from_millis(100), Duration::from_secs(1), ); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); assert!(terminal.managed_agent_launch_pending()); assert!(!terminal.managed_agent_interactive_ready()); assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); assert!(!terminal.managed_agent_launch_pending()); assert!(terminal.managed_agent_interactive_ready()); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); assert!(terminal.managed_agent_interactive_ready()); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.managed_agent_interactive_ready()); assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); assert_eq!(terminal.agent_name, None); } #[test] fn managed_agent_mismatch_and_timeout_release_name() { let now = Instant::now(); let mut mismatch = test_terminal(); mismatch.begin_managed_agent( "reviewer".into(), Agent::Pi, now, Duration::ZERO, Duration::from_secs(1), ); mismatch.set_detected_state(Some(Agent::Codex), AgentState::Idle); assert!(mismatch.reconcile_managed_agent_at(now, false)); assert_eq!(mismatch.agent_name, None); assert_eq!(mismatch.managed_agent_kind(), None); let mut timed_out = test_terminal(); timed_out.begin_managed_agent( "reviewer".into(), Agent::Pi, now, Duration::from_millis(10), Duration::from_millis(20), ); assert!(timed_out.reconcile_managed_agent_at(now + Duration::from_millis(20), false)); assert_eq!(timed_out.agent_name, None); assert_eq!(timed_out.managed_agent_kind(), None); } #[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 pi_session_replacement_reports_reanchor_full_lifecycle_authority() { for reason in ["new", "resume", "fork"] { let mut terminal = test_terminal(); let old_session = test_session_path(&format!("pi-{reason}-old.jsonl")); let new_session = test_session_path(&format!("pi-{reason}-new.jsonl")); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(old_session), Some(10), ); let session_report = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(11), Some(reason.into()), ); assert!( session_report.is_some(), "{reason} should replace the previous Pi session" ); assert!(terminal.hook_authority.is_none()); let working = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); assert!( working.is_some(), "{reason} should accept working for the replacement session" ); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.hook_authority.as_ref().unwrap().session_ref, crate::agent_resume::AgentSessionRef::path(new_session) ); } } #[test] fn pi_resume_reactivates_a_previously_stale_session() { let mut terminal = test_terminal(); let session_a = test_session_path("pi-session-a.jsonl"); let session_b = test_session_path("pi-session-b.jsonl"); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(10), ); terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::path(session_b.clone()), Some(11), Some("new".into()), ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(session_b.clone()), Some(12), ); let resumed = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(13), Some("resume".into()), ); let working = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(14), ); assert!(resumed.is_some()); assert!(working.is_some()); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.hook_authority.as_ref().unwrap().session_ref, crate::agent_resume::AgentSessionRef::path(session_a) ); let late_session_b = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(session_b), Some(15), ); assert!(late_session_b.is_none()); assert_eq!(terminal.state, AgentState::Working); } #[test] fn pi_startup_adopts_persisted_session_without_live_authority() { let mut terminal = test_terminal(); let old_session = test_session_path("pi-startup-old.jsonl"); let new_session = test_session_path("pi-startup-new.jsonl"); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), session_ref: crate::agent_resume::AgentSessionRef::path(old_session) .expect("test session path should be valid"), }); let startup = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(11), Some("startup".into()), ); assert!(startup.is_some()); assert_eq!( terminal.current_session_identity_for_persistence(), Some(( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRefKind::Path, new_session, )) ); } #[test] fn pi_non_replacement_reports_preserve_full_lifecycle_authority() { for reason in [None, Some("reload"), Some("startup")] { let mut terminal = test_terminal(); let old_session = test_session_path("pi-current.jsonl"); let new_session = test_session_path("pi-unexpected.jsonl"); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); let session_report = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(11), reason.map(str::to_string), ); let working = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(new_session), Some(12), ); assert!(session_report.is_none()); assert!(working.is_none()); assert_eq!(terminal.state, AgentState::Idle); assert_eq!( terminal.hook_authority.as_ref().unwrap().session_ref, crate::agent_resume::AgentSessionRef::path(old_session), "{reason:?} must not replace the current Pi session" ); } } #[test] fn omp_resume_session_report_reanchors_full_lifecycle_authority() { let mut terminal = test_terminal(); let old_session = test_session_path("omp-old.jsonl"); let new_session = test_session_path("omp-new.jsonl"); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); let session_report = terminal.set_agent_session_ref_for_session_start( "herdr:omp".into(), "omp".into(), crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(11), Some("resume".into()), ); assert!(session_report.is_some()); assert!(terminal.hook_authority.is_none()); assert_eq!( terminal .persisted_agent_session .as_ref() .unwrap() .session_ref, crate::agent_resume::AgentSessionRef::path(new_session.clone()).unwrap() ); let blocked = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Blocked, Some("waiting".into()), crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); assert!(blocked.is_some()); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!( terminal.hook_authority.as_ref().unwrap().session_ref, crate::agent_resume::AgentSessionRef::path(new_session) ); let stale = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(old_session), Some(13), ); assert!(stale.is_none()); assert_eq!(terminal.state, AgentState::Blocked); } #[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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(session_a), Some(250), now + Duration::from_millis(8), ); let generation_c = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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, 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() { assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( "herdr:omp", "omp", ); } #[test] fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( "herdr:mastracode", "mastracode", ); } #[test] fn mastracode_lifecycle_report_replaces_restored_thread_ref() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:mastracode".into(), agent: "mastracode".into(), session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(), }); let mutation = terminal .set_hook_authority_with_session_ref( "herdr:mastracode".into(), "mastracode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("mastracode-new"), Some(20), ) .expect("fresh MastraCode thread should replace restored thread id"); assert!(mutation.session_ref_changed); assert_eq!( terminal.current_session_identity_for_persistence(), Some(( "herdr:mastracode".into(), "mastracode".into(), crate::agent_resume::AgentSessionRefKind::Id, "mastracode-new".into() )) ); } fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( source: &str, agent_label: &str, ) { let mut terminal = test_terminal(); let old_session = format!("{agent_label}-old"); let new_session = format!("{agent_label}-new"); terminal.set_hook_authority_with_session_ref( source.into(), agent_label.into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(20), ); terminal.release_agent(source, agent_label, Some(21)); // A late report from the released run keeps its old session ref and stays // suppressed, so a just-exited agent cannot resurrect the pane. let stale = terminal.set_hook_authority_with_session_ref( source.into(), agent_label.into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(22), ); assert!(stale.is_none()); assert!(terminal.hook_authority.is_none()); // A fresh run carries a new session ref and reacquires authority. let fresh = terminal.set_hook_authority_with_session_ref( source.into(), agent_label.into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id(&new_session), 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_at( "herdr:omp".into(), "omp".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(1000), now, ); 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, 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, 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 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_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, 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); } #[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_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, 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); } #[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_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, 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_at( "herdr:hermes".into(), "hermes".into(), AgentState::Idle, 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_at( "herdr:kilo".into(), "kilo".into(), AgentState::Idle, 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_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, 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_at( "herdr:codex".into(), "codex".into(), AgentState::Blocked, None, 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!(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_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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_at( "herdr:claude".into(), "claude".into(), AgentState::Working, 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_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, Some(1), observed, ); terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, 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.reported_at, observed + Duration::from_secs(1)); 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, 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, 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, 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, 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, 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, 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 pi_session_replacement_clears_the_previous_sessions_alias() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal .set_agent_session_ref( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::id("pi-old"), Some(20), ) .expect("initial session should be accepted"); terminal.set_agent_name("reviewer".into()); terminal .set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::id("pi-new"), Some(21), Some("new".into()), ) .expect("new should replace the session"); assert!(terminal.agent_name.is_none()); } #[test] fn managed_agent_name_survives_native_session_replacement() { let mut terminal = test_terminal(); let now = Instant::now(); terminal.begin_managed_agent( "reviewer".into(), Agent::OpenCode, now, Duration::ZERO, Duration::from_secs(1), ); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); assert!(terminal.reconcile_managed_agent_at(now, false)); for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] { terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id(session), Some(sequence), Some("new".into()), ) .expect("managed session should be accepted"); } assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(terminal.managed_agent_interactive_ready()); 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 foreground_agent_session_replaces_stale_different_owner_session_ref() { for session_start_source in ["resume", "startup"] { let mut terminal = test_terminal(); 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::Claude), AgentState::Idle); 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(session_start_source.into()), ) .unwrap_or_else(|| { panic!("{session_start_source} should replace stale codex session") }); 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")), "{session_start_source} should store claude session" ); } } #[test] fn foreground_agent_session_requires_lifecycle_source_to_replace_different_owner() { for session_start_source in [None, Some("other")] { let mut terminal = test_terminal(); 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::Claude), AgentState::Idle); let mutation = terminal.set_agent_session_ref_for_session_start( "herdr:claude".into(), "claude".into(), crate::agent_resume::AgentSessionRef::id("claude-session"), Some(21), session_start_source.map(str::to_string), ); assert!( mutation.is_none(), "{session_start_source:?} should not replace" ); 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:codex", "codex", "codex-session")) ); } } #[test] fn different_owner_session_ref_requires_matching_detected_agent() { for session_start_source in ["startup", "resume"] { for detected_agent in [None, Some(Agent::Codex)] { let mut terminal = test_terminal(); 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(detected_agent, AgentState::Idle); 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(session_start_source.into()), ); assert!( mutation.is_none(), "{session_start_source} with {detected_agent:?} should not replace" ); 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:codex", "codex", "codex-session")) ); } } } #[test] fn custom_session_report_does_not_replace_different_owner_session_ref() { let mut terminal = test_terminal(); 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::Claude), AgentState::Idle); let mutation = terminal.set_agent_session_ref_for_session_start( "custom: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:codex", "codex", "codex-session")) ); } #[test] fn foreground_agent_session_replaces_stale_different_owner_hook_authority() { let mut terminal = test_terminal(); let now = std::time::Instant::now(); terminal .set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), now + Duration::from_millis(1), ) .expect("initial hook authority should be accepted"); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Codex), AgentState::Idle, false, false, false, false, now, ); let mutation = terminal .set_agent_session_ref_for_session_start( "herdr:codex".into(), "codex".into(), crate::agent_resume::AgentSessionRef::id("codex-session"), Some(21), Some("startup".into()), ) .expect("foreground codex should replace stale hook authority"); assert!(mutation.session_ref_changed); assert!(terminal.hook_authority.is_none()); assert_eq!( terminal.current_session_identity_for_persistence(), Some(( "herdr:codex".into(), "codex".into(), crate::agent_resume::AgentSessionRefKind::Id, "codex-session".into() )) ); let late_old_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(22), ); assert!(late_old_session.is_none()); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); let fresh_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("opencode-new-session"), Some(23), ); assert!(fresh_session.is_some()); } #[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, 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, 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()), 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, 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, 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 agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); terminal.set_agent_name("reviewer".into()); terminal.set_detected_state(None, AgentState::Unknown); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); assert!(terminal.agent_name.is_none()); terminal.set_agent_name("replacement".into()); terminal .release_agent_with_mutation("herdr:codex", "codex", None) .expect("detected agent release should be accepted"); assert!(terminal.agent_name.is_none()); } #[test] fn agent_replacement_clears_alias_owned_by_hook_identity() { let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:claude".into(), "claude".into(), AgentState::Working, None, Some(20), ); terminal.set_agent_name("reviewer".into()); terminal.set_detected_state(Some(Agent::Grok), AgentState::Idle); assert!(terminal.agent_name.is_none()); assert_eq!(terminal.effective_known_agent(), Some(Agent::Grok)); } #[test] fn accepted_hook_replacement_clears_the_previous_agents_alias() { let mut terminal = test_terminal(); terminal .set_hook_authority_at( "custom:agent".into(), "pi".into(), AgentState::Working, None, None, Some(20), Instant::now(), ) .expect("initial hook should be accepted"); terminal.set_agent_name("reviewer".into()); terminal .set_hook_authority_at( "custom:agent".into(), "claude".into(), AgentState::Idle, None, None, Some(21), Instant::now(), ) .expect("replacement hook should be accepted"); assert!(terminal.agent_name.is_none()); assert_eq!(terminal.effective_known_agent(), Some(Agent::Claude)); } #[test] fn accepted_same_kind_hook_owner_replacement_clears_the_alias() { let mut terminal = test_terminal(); terminal .set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")), Some(20), Instant::now(), ) .expect("initial hook should be accepted"); terminal.set_agent_name("reviewer".into()); terminal .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21)) .expect("hook clear should be accepted"); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); terminal .set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Idle, None, crate::agent_resume::AgentSessionRef::path(test_session_path("second.jsonl")), Some(22), Instant::now(), ) .expect("replacement hook should be accepted"); assert!(terminal.agent_name.is_none()); assert_eq!(terminal.effective_known_agent(), Some(Agent::Pi)); } #[test] fn launch_command_alone_does_not_make_a_terminal_an_agent() { let terminal = test_terminal().with_launch_argv(vec!["just".into(), "dev".into()]); assert!(!terminal.is_agent_terminal()); } #[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 agent_process_exit_tracks_recent_respawn_window() { let mut terminal = test_terminal(); let now = std::time::Instant::now(); terminal.set_detected_state_with_screen_signals_at( Some(Agent::OpenCode), AgentState::Idle, false, false, false, true, now, ); assert!(terminal.agent_process_exited_within(now, Duration::from_secs(2))); assert!(!terminal .agent_process_exited_within(now + Duration::from_secs(3), Duration::from_secs(2))); terminal.set_detected_state_with_screen_signals_at( Some(Agent::OpenCode), AgentState::Working, false, false, true, false, now + Duration::from_secs(4), ); assert!(!terminal .agent_process_exited_within(now + Duration::from_secs(4), Duration::from_secs(2))); } #[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, 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, 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" ); } }