aboutsummaryrefslogtreecommitdiff
path: root/src/terminal/state.rs
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-10 14:57:44 +0300
committerGitHub <noreply@github.com>2026-08-10 14:57:44 +0300
commitccccda54c21f6ef1649a44bb098d7c498724404d (patch)
tree86f28b85086ef36fd7ebc0f1d928b7606d76c487 /src/terminal/state.rs
parent2e8cc9060d723a5ac32a2325e695fd8750ff60fe (diff)
fix: track opencode tui session selection (#2455)
refs #2450 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/terminal/state.rs')
-rw-r--r--src/terminal/state.rs354
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(),