diff options
Diffstat (limited to 'src/terminal/state.rs')
| -rw-r--r-- | src/terminal/state.rs | 354 |
1 files changed, 324 insertions, 30 deletions
diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 5ed5adc4..56064a94 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -844,7 +844,7 @@ impl TerminalState { let process_present = known_agent.is_some() && self.detected_agent == known_agent && self.recent_agent_process_exit.is_none(); - let session_anchored = self + let anchored_session_ref = self .hook_authority .as_ref() .filter(|authority| authority.source == source && authority.agent_label == agent_label) @@ -854,12 +854,20 @@ impl TerminalState { .as_ref() .filter(|session| session.source == source && session.agent == agent_label) .map(|session| &session.session_ref) - }) - .is_some_and(|anchored| { - session_ref - .as_ref() - .is_none_or(|incoming| incoming == anchored) }); + let session_anchored = anchored_session_ref.is_some_and(|anchored| { + session_ref + .as_ref() + .is_none_or(|incoming| incoming == anchored) + }); + let opencode_cross_talk = (source, agent_label) == ("herdr:opencode", "opencode") + && process_present + && anchored_session_ref + .zip(session_ref.as_ref()) + .is_some_and(|(anchored, incoming)| anchored != incoming); + if opencode_cross_talk { + return FullLifecycleHookReportRoute::Ignore; + } if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) { if suppressed.agent_label != agent_label { return FullLifecycleHookReportRoute::Ignore; @@ -1284,7 +1292,7 @@ impl TerminalState { Some("startup" | "clear" | "resume" | "compact") ) | ("herdr:mastracode", "mastracode", Some("startup")) | ("herdr:hermes", "hermes", Some("startup" | "new" | "resume")) - | ("herdr:opencode", "opencode", Some("new")) + | ("herdr:opencode", "opencode", Some("select")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", @@ -1298,10 +1306,20 @@ impl TerminalState { fn session_start_source_is_recognized(session_start_source: Option<&str>) -> bool { matches!( session_start_source, - Some("startup" | "clear" | "resume" | "compact" | "new" | "fork") + Some("startup" | "clear" | "resume" | "compact" | "new" | "fork" | "select") ) } + fn is_unsequenced_opencode_selection( + source: &str, + agent_label: &str, + session_start_source: Option<&str>, + seq: Option<u64>, + ) -> bool { + (source, agent_label, session_start_source, seq) + == ("herdr:opencode", "opencode", Some("select"), None) + } + pub fn set_persisted_agent_session( &mut self, session: crate::agent_resume::PersistedAgentSession, @@ -1346,7 +1364,48 @@ impl TerminalState { && authority.agent_label == agent_label && authority.session_ref.is_some() }) || self.persisted_agent_session_matches(&source, &agent_label); - if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) { + let unsequenced_selection = Self::is_unsequenced_opencode_selection( + &source, + &agent_label, + session_start_source.as_deref(), + seq, + ); + let selection_can_reconcile = unsequenced_selection && process_present; + if selection_can_reconcile { + self.suppressed_full_lifecycle_hook_reports.remove(&source); + } else if full_lifecycle_source && unsequenced_selection { + let previous_session_ref = self + .hook_authority + .as_ref() + .filter(|authority| { + authority.source == source && authority.agent_label == agent_label + }) + .and_then(|authority| authority.session_ref.clone()) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| session.session_ref.clone()) + }); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label, + session_ref: previous_session_ref, + observed_at: Instant::now(), + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + suppressed.replacement_session_ref = Some(session_ref); + suppressed.pending_replacement_report = None; + return None; + } + if full_lifecycle_source + && !selection_can_reconcile + && (!process_present || generation_gated || !session_anchored) + { if !Self::session_start_source_is_recognized(session_start_source.as_deref()) { return None; } @@ -1408,7 +1467,7 @@ impl TerminalState { } return None; } - if !self.accept_hook_report(&source, seq) { + if !unsequenced_selection && !self.accept_hook_report(&source, seq) { return None; } if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { @@ -4426,37 +4485,272 @@ mod tests { } #[test] - fn opencode_new_session_ref_replaces_existing_session_ref() { + fn opencode_server_new_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), - crate::agent_resume::AgentSessionRef::id("opencode-old"), + crate::agent_resume::AgentSessionRef::id("opencode-visible"), + None, + Some("select".into()), + ) + .expect("local selection should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-attached-client"), + Some(21), + Some("new".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-visible") + ); + } + + #[test] + fn opencode_server_resume_does_not_replace_existing_session_ref() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-visible"), + None, + Some("select".into()), + ) + .expect("local selection should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-attached-client"), + Some(21), + Some("resume".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-visible") + ); + } + + #[test] + fn opencode_tui_selection_anchors_after_process_detection() { + let mut terminal = test_terminal(); + let startup_selection = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-startup-selection"), + None, + Some("select".into()), + ); + assert!(startup_selection.is_none()); + assert_eq!( + terminal + .suppressed_full_lifecycle_hook_reports + .get("herdr:opencode") + .and_then(|suppressed| suppressed.replacement_session_ref.as_ref()) + .map(|session| session.value.as_str()), + Some("opencode-startup-selection") + ); + + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-startup-selection") + ); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + + terminal.suppressed_full_lifecycle_hook_reports.insert( + "herdr:opencode".into(), + SuppressedFullLifecycleHookReport { + agent_label: "opencode".into(), + session_ref: None, + observed_at: Instant::now(), + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }, + ); + let selected = terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-reselected"), + None, + Some("select".into()), + ) + .expect("local TUI selection should reconcile generation suppression"); + + assert!(selected.session_ref_changed); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-reselected") + ); + assert!(!terminal + .hook_report_sequences + .contains_key("herdr:opencode")); + } + + #[test] + fn opencode_tui_selection_reanchors_full_lifecycle_authority() { + let mut terminal = test_terminal(); + let old_session = crate::agent_resume::AgentSessionRef::id("opencode-newer").unwrap(); + let selected_session = + crate::agent_resume::AgentSessionRef::id("opencode-selected-older").unwrap(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + old_session.clone(), + ); + terminal + .set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + Some(old_session.clone()), Some(20), - Some("new".into()), ) - .expect("initial session should be accepted"); + .expect("initial session should own lifecycle state"); + let attached_session = + crate::agent_resume::AgentSessionRef::id("opencode-attached-client").unwrap(); + let attached = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Working, + None, + Some(attached_session.clone()), + Some(21), + ); + assert!(attached.is_none()); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); - let mutation = terminal + let selected = terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), - crate::agent_resume::AgentSessionRef::id("opencode-new"), + Some(selected_session.clone()), + None, + Some("select".into()), + ) + .expect("selected session should replace the previous session"); + + assert!(selected.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + assert_eq!( + terminal.hook_report_sequences.get("herdr:opencode"), + Some(&20) + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&selected_session) + ); + + terminal + .set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Working, + None, + Some(selected_session.clone()), Some(21), - Some("new".into()), ) - .expect("new should replace the session"); + .expect("selected session should regain lifecycle authority"); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()), + Some(&selected_session) + ); - assert!(mutation.session_ref_changed); + let late_old_session = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + Some(old_session), + Some(22), + ); + assert!(late_old_session.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()), + Some(&selected_session) + ); + + let late_attached_session = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + None, + Some(attached_session), + Some(23), + ); + assert!(late_attached_session.is_none()); + assert_eq!(terminal.state, AgentState::Working); + + let final_session = + crate::agent_resume::AgentSessionRef::id("opencode-final-selection").unwrap(); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + Some(final_session.clone()), + None, + Some("select".into()), + ) + .expect("another local selection should remain authoritative"); assert_eq!( terminal .persisted_agent_session .as_ref() - .map(|session| session.session_ref.value.as_str()), - Some("opencode-new") + .map(|session| &session.session_ref), + Some(&final_session) ); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); } #[test] @@ -4501,14 +4795,14 @@ mod tests { 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")] { + for session in ["opencode-old", "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()), + None, + Some("select".into()), ) .expect("managed session should be accepted"); } @@ -4533,10 +4827,10 @@ mod tests { "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), - Some(20), - Some("new".into()), + None, + Some("select".into()), ) - .expect("initial session should be accepted"); + .expect("local selection should be accepted"); // session.updated reports carry no session_start_source, so a different // id must not displace the established session (cross-talk guard). @@ -4793,10 +5087,10 @@ mod tests { "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-new-session"), - Some(23), - Some("new".into()), + None, + Some("select".into()), ) - .expect("fresh root session"); + .expect("fresh local selection"); let fresh_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), |
