diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-23 21:27:26 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-23 21:37:05 +0300 |
| commit | db67e9d74ffafc9c63bdf78fcf712419359f634b (patch) | |
| tree | 9d181b55e65aaf6a2fad2d6cf785bb09a8d9748f /src/terminal | |
| parent | 1bab1d2ce21507747ea1f506fb891fe7cd5a13c9 (diff) | |
feat: restore agent sessions
refs #233
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/mod.rs | 2 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 29 | ||||
| -rw-r--r-- | src/terminal/state.rs | 515 |
3 files changed, 510 insertions, 36 deletions
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 7e43f7a8..663cb6fe 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -6,4 +6,4 @@ pub mod state; pub use id::TerminalId; pub use runtime::TerminalRuntime; pub(crate) use runtime_registry::TerminalRuntimeRegistry; -pub use state::{EffectiveStateChange, TerminalState}; +pub use state::{EffectiveStateChange, TerminalState, TerminalStateMutation}; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 43c2467d..be69b713 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -102,6 +102,35 @@ impl TerminalRuntime { .map(Self) } + pub fn spawn_agent_restore( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + restore_plan: &crate::agent_resume::AgentResumePlan, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + default_shell: &str, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + crate::pane::PaneRuntime::spawn_agent_restore( + pane_id, + rows, + cols, + cwd, + restore_plan, + scrollback_limit_bytes, + host_terminal_theme, + default_shell, + events, + render_notify, + render_dirty, + ) + .map(Self) + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.0.apply_host_terminal_theme(theme); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 7f9a254a..b94be3e7 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -22,6 +22,7 @@ pub struct HookAuthority { pub message: Option<String>, pub custom_status: Option<String>, pub reported_at: Instant, + pub session_ref: Option<crate::agent_resume::AgentSessionRef>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +36,12 @@ pub struct EffectiveStateChange { pub custom_status: Option<String>, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TerminalStateMutation { + pub effective_state_change: Option<EffectiveStateChange>, + pub session_ref_changed: bool, +} + /// Pure state for a server-owned terminal. /// /// During the migration this is still one-to-one with a pane-backed PTY, but @@ -51,6 +58,7 @@ pub struct TerminalState { fallback_observed_at: Option<Instant>, stale_hook_idle_since: Option<Instant>, pub hook_authority: Option<HookAuthority>, + pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, pub manual_label: Option<String>, pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, @@ -72,6 +80,7 @@ impl TerminalState { fallback_observed_at: None, stale_hook_idle_since: None, hook_authority: None, + persisted_agent_session: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), @@ -96,6 +105,23 @@ impl TerminalState { } #[cfg(test)] + pub fn set_detected_state_with_mutation( + &mut self, + agent: Option<Agent>, + 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<Agent>, @@ -113,6 +139,7 @@ impl TerminalState { process_exited, Instant::now(), ) + .effective_state_change } pub fn set_detected_state_with_screen_signals_at( @@ -124,11 +151,12 @@ impl TerminalState { visible_working: bool, process_exited: bool, now: Instant, - ) -> Option<EffectiveStateChange> { + ) -> 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_detected_agent = self.detected_agent; + let previous_session = self.current_session_identity_for_persistence(); self.detected_agent = agent; self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; @@ -156,13 +184,26 @@ impl TerminalState { self.hook_authority = None; self.stale_hook_idle_since = None; } + let detected_agent_changed_or_disappeared = + previous_detected_agent.is_some() && agent != previous_detected_agent; + let persisted_agent_was_previously_detected = + self.persisted_agent_session_belongs_to_detected_agent(previous_detected_agent); + if self.persisted_agent_session_conflicts_with_detected_agent(agent) + || detected_agent_changed_or_disappeared && persisted_agent_was_previously_detected + { + self.persisted_agent_session = None; + } self.update_stale_hook_idle_window(now); - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - now, - ) + TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session + != self.current_session_identity_for_persistence(), + } } #[cfg(test)] @@ -177,6 +218,7 @@ impl TerminalState { self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) } + #[cfg(test)] pub fn set_hook_authority_with_custom_status( &mut self, source: String, @@ -192,6 +234,30 @@ impl TerminalState { state, message, custom_status, + None, + seq, + Instant::now(), + ) + .and_then(|mutation| mutation.effective_state_change) + } + + pub fn set_hook_authority_with_session_ref( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + custom_status: Option<String>, + session_ref: Option<crate::agent_resume::AgentSessionRef>, + seq: Option<u64>, + ) -> Option<TerminalStateMutation> { + self.set_hook_authority_with_custom_status_at( + source, + agent_label, + state, + message, + custom_status, + session_ref, seq, Instant::now(), ) @@ -204,9 +270,10 @@ impl TerminalState { state: AgentState, message: Option<String>, custom_status: Option<String>, + session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, now: Instant, - ) -> Option<EffectiveStateChange> { + ) -> Option<TerminalStateMutation> { if !self.accept_hook_report(&source, seq) { return None; } @@ -214,9 +281,11 @@ impl TerminalState { 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_session = self.current_session_identity_for_persistence(); if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } + self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { source, agent_label, @@ -224,14 +293,19 @@ impl TerminalState { message, custom_status, reported_at: now, + session_ref, }); self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - now, - ) + 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, + now, + ), + session_ref_changed: previous_session != current_session, + }) } fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool { @@ -257,6 +331,73 @@ impl TerminalState { }) } + fn persisted_agent_session_conflicts_with_detected_agent( + &self, + detected_agent: Option<Agent>, + ) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.persisted_agent_session + .as_ref() + .and_then(|session| crate::detect::parse_agent_label(&session.agent)) + .is_some_and(|agent| agent != detected_agent) + } + + fn persisted_agent_session_belongs_to_detected_agent( + &self, + detected_agent: Option<Agent>, + ) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.persisted_agent_session + .as_ref() + .and_then(|session| crate::detect::parse_agent_label(&session.agent)) + .is_some_and(|agent| 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 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(), + ) + }) + } + + pub fn set_persisted_agent_session( + &mut self, + session: crate::agent_resume::PersistedAgentSession, + ) { + self.persisted_agent_session = Some(session); + } + fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { let Some(detected_agent) = self.detected_agent else { return false; @@ -282,11 +423,21 @@ impl TerminalState { true } + #[cfg(test)] pub fn clear_hook_authority( &mut self, source: Option<&str>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { + 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<u64>, + ) -> Option<TerminalStateMutation> { let sequence_source = source.map(str::to_string).or_else(|| { self.hook_authority .as_ref() @@ -301,6 +452,7 @@ impl TerminalState { 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_session = self.current_session_identity_for_persistence(); let should_clear = self .hook_authority .as_ref() @@ -310,26 +462,37 @@ impl TerminalState { } self.hook_authority = None; self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - Instant::now(), - ) + self.persisted_agent_session = None; + let now = Instant::now(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session.is_some(), + }) } + #[cfg(test)] pub fn release_agent( &mut self, source: &str, agent_label: &str, seq: Option<u64>, ) -> Option<EffectiveStateChange> { - if !self.accept_hook_report(source, seq) { - return None; - } + self.release_agent_with_mutation(source, agent_label, seq) + .and_then(|mutation| mutation.effective_state_change) + } - let current_agent_label = self.effective_agent_label()?; - if current_agent_label != agent_label { + pub fn release_agent_with_mutation( + &mut self, + source: &str, + agent_label: &str, + seq: Option<u64>, + ) -> Option<TerminalStateMutation> { + if !self.accept_hook_report(source, seq) { return None; } @@ -339,9 +502,16 @@ impl TerminalState { 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 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_session = self.current_session_identity_for_persistence(); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -350,12 +520,17 @@ impl TerminalState { self.fallback_observed_at = None; self.hook_authority = None; self.stale_hook_idle_since = None; - self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - Instant::now(), - ) + self.persisted_agent_session = None; + let now = Instant::now(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + now, + ), + session_ref_changed: previous_session.is_some(), + }) } pub fn effective_agent_label(&self) -> Option<&str> { @@ -860,6 +1035,7 @@ mod tests { None, Some("thinking".into()), None, + None, now, ); @@ -873,7 +1049,7 @@ mod tests { now + Duration::from_millis(500), ); - assert!(waiting.is_none()); + assert!(waiting.effective_state_change.is_none()); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.effective_custom_status(), Some("thinking")); @@ -890,7 +1066,10 @@ mod tests { assert_eq!(terminal.state, AgentState::Idle); assert_eq!(terminal.effective_custom_status(), None); - assert_eq!(change.unwrap().previous_state, AgentState::Working); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Working + ); } #[test] @@ -905,6 +1084,7 @@ mod tests { None, Some("thinking".into()), None, + None, now, ); terminal.set_detected_state_with_screen_signals_at( @@ -923,6 +1103,7 @@ mod tests { AgentState::Working, None, Some("thinking".into()), + None, Some(1), now + Duration::from_millis(800), ); @@ -936,7 +1117,7 @@ mod tests { now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), ); - assert!(change.is_none()); + assert!(change.effective_state_change.is_none()); assert_eq!(terminal.state, AgentState::Working); } @@ -952,6 +1133,7 @@ mod tests { None, None, None, + None, now, ); @@ -966,7 +1148,10 @@ mod tests { ); assert_eq!(terminal.state, AgentState::Working); - assert_eq!(change.unwrap().previous_state, AgentState::Idle); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Idle + ); } #[test] @@ -1183,6 +1368,7 @@ mod tests { AgentState::Working, None, None, + None, Some(1), observed + Duration::from_secs(1), ); @@ -1220,6 +1406,7 @@ mod tests { AgentState::Working, None, None, + None, Some(1), observed, ); @@ -1229,6 +1416,7 @@ mod tests { AgentState::Working, None, Some("new turn".into()), + None, Some(2), observed + Duration::from_secs(1), ); @@ -1317,6 +1505,263 @@ mod tests { } #[test] + fn accepted_hook_report_stores_session_ref() { + let mut terminal = test_terminal(); + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + 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, + "/tmp/pi.jsonl" + )) + ); + } + + #[test] + fn stale_hook_report_cannot_overwrite_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/new.jsonl"), + 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("/tmp/pi.jsonl") + ); + } + + #[test] + fn accepted_hook_report_without_session_ref_clears_previous_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(21), + ) + .expect("accepted report"); + + assert!(mutation.session_ref_changed); + assert!(mutation.effective_state_change.is_none()); + assert!(terminal + .hook_authority + .as_ref() + .unwrap() + .session_ref + .is_none()); + } + + #[test] + fn accepted_hook_report_marks_changed_when_session_identity_changes() { + 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("same-session").unwrap(), + }); + + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:hermes".into(), + "hermes".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("same-session"), + Some(20), + ) + .expect("accepted report"); + + assert!(mutation.session_ref_changed); + } + + #[test] + fn clearing_hook_authority_clears_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + 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(); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"), + Some(20), + ); + + let mutation = terminal + .release_agent_with_mutation("herdr:pi", "pi", Some(21)) + .expect("accepted release"); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + } + + #[test] + fn release_agent_clears_matching_restored_session_ref_before_detection() { + let mut terminal = test_terminal(); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:hermes".into(), + agent: "hermes".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + }); + + let mutation = terminal + .release_agent_with_mutation("herdr:hermes", "hermes", Some(21)) + .expect("accepted release"); + + assert!(mutation.session_ref_changed); + assert!(mutation.effective_state_change.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + } + + #[test] + fn detected_conflict_clears_session_ref() { + let mut terminal = test_terminal(); + terminal.set_hook_authority_with_session_ref( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("claude-session"), + Some(20), + ); + + let mutation = + terminal.set_detected_state_with_mutation(Some(Agent::Grok), AgentState::Idle); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + } + + #[test] + fn detected_agent_disappearance_clears_matching_hook_session_ref() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:hermes".into(), + "hermes".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("hermes-session"), + Some(20), + ); + + let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); + + assert!(mutation.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + assert_eq!(terminal.effective_agent_label(), None); + } + + #[test] + fn detected_agent_disappearance_clears_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_none()); + } + + #[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( |
