aboutsummaryrefslogtreecommitdiff
path: root/src/terminal
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 21:27:26 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 21:37:05 +0300
commitdb67e9d74ffafc9c63bdf78fcf712419359f634b (patch)
tree9d181b55e65aaf6a2fad2d6cf785bb09a8d9748f /src/terminal
parent1bab1d2ce21507747ea1f506fb891fe7cd5a13c9 (diff)
feat: restore agent sessions
refs #233
Diffstat (limited to 'src/terminal')
-rw-r--r--src/terminal/mod.rs2
-rw-r--r--src/terminal/runtime.rs29
-rw-r--r--src/terminal/state.rs515
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(