diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:31:53 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:32:44 +0300 |
| commit | 36a4608603a82dbf32ec8a013dd47047cf02c6a3 (patch) | |
| tree | 8a9242974ad45dd6dc22b66f576dd9adcc36d996 /src/app | |
| parent | 5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (diff) | |
fix: restore agents through pane shells
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/agent_resume.rs | 587 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 13 | ||||
| -rw-r--r-- | src/app/runtime.rs | 98 |
4 files changed, 695 insertions, 5 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs new file mode 100644 index 00000000..3523da05 --- /dev/null +++ b/src/app/agent_resume.rs @@ -0,0 +1,587 @@ +use std::time::Instant; + +use bytes::Bytes; + +use super::App; + +struct PendingAgentResumeCandidate { + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, +} + +impl App { + pub(crate) fn has_pending_agent_resumes(&self) -> bool { + self.state + .terminals + .values() + .any(|terminal| terminal.pending_agent_resume_plan.is_some()) + } + + pub(crate) fn sync_pending_agent_resume_deadline(&mut self, now: Instant) { + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + return; + } + if self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + return; + } + self.pending_agent_resume_deadline + .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT); + } + + pub(crate) fn pending_agent_resume_due(&self, now: Instant) -> bool { + self.pending_agent_resume_deadline + .is_some_and(|deadline| now >= deadline) + } + + pub(crate) fn start_pending_agent_resumes(&mut self, allow_empty_theme: bool) -> bool { + let pending = self.pending_agent_resume_candidates(); + let mut changed = false; + for PendingAgentResumeCandidate { + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + } in pending + { + if self.terminal_runtimes.get(&terminal_id).is_some() { + continue; + } + changed |= self.start_pending_agent_resume( + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + } + + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() || self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn pending_agent_resume_candidates(&self) -> Vec<PendingAgentResumeCandidate> { + let Some(ws_idx) = self.state.active else { + return Vec::new(); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return Vec::new(); + }; + let Some(tab) = ws.tabs.get(ws.active_tab) else { + return Vec::new(); + }; + + let mut pending = Vec::new(); + for pane_id in tab.layout.pane_ids() { + let Some(pane) = tab.panes.get(&pane_id) else { + continue; + }; + if self + .terminal_runtimes + .get(&pane.attached_terminal_id) + .is_some() + { + continue; + } + let Some(info) = self + .state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + else { + continue; + }; + let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else { + continue; + }; + let Some(plan) = terminal.pending_agent_resume_plan.clone() else { + continue; + }; + pending.push(PendingAgentResumeCandidate { + pane_id, + terminal_id: pane.attached_terminal_id.clone(), + cwd: terminal.cwd.clone(), + plan, + rows: info.inner_rect.height, + cols: info.inner_rect.width, + }); + } + pending + } + + pub(crate) fn start_pending_agent_resume_for_terminal( + &mut self, + terminal_id: &crate::terminal::TerminalId, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + if self.terminal_runtimes.get(terminal_id).is_some() { + return false; + } + let Some((pane_id, cwd, plan)) = self.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.layout.pane_ids().into_iter().find_map(|pane_id| { + let pane = tab.panes.get(&pane_id)?; + if &pane.attached_terminal_id != terminal_id { + return None; + } + let terminal = self.state.terminals.get(terminal_id)?; + Some(( + pane_id, + terminal.cwd.clone(), + terminal.pending_agent_resume_plan.clone()?, + )) + }) + }) + }) else { + return false; + }; + + let changed = self.start_pending_agent_resume( + pane_id, + terminal_id.clone(), + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn start_pending_agent_resume( + &mut self, + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + let host_terminal_theme = self.state.host_terminal_theme; + if host_terminal_theme.is_empty() && !allow_empty_theme { + return false; + } + + let Some(resume_command) = shell_command_from_argv(&plan.argv) else { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + "failed to start deferred agent resume with empty argv" + ); + return false; + }; + + let runtime = match crate::terminal::TerminalRuntime::spawn( + pane_id, + rows, + cols, + cwd, + self.state.pane_scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) { + Ok(runtime) => runtime, + Err(err) => { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to start shell for deferred agent resume" + ); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.clear_agent_runtime_identity_after_respawn(); + } + return false; + } + }; + + let mut input = resume_command; + input.push('\r'); + if let Err(err) = runtime.try_send_bytes(Bytes::from(input)) { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to send deferred agent resume command to shell" + ); + runtime.shutdown(); + return false; + } + + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.pending_agent_resume_plan = None; + terminal.respawn_shell_on_exit = false; + } + true + } +} + +fn shell_command_from_argv(argv: &[String]) -> Option<String> { + let mut parts = argv.iter(); + let first = shell_quote(parts.next()?); + let mut command = first; + for part in parts { + command.push(' '); + command.push_str(&shell_quote(part)); + } + Some(command) +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' + ) + }) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + #[tokio::test] + async fn pending_agent_resume_waits_for_host_theme_before_launch() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + let pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.view.pane_infos = pane_infos; + let terminal = app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist"); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(), + ], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + let terminal = app + .state + .terminals + .get(&terminal_id) + .expect("terminal should survive launch"); + assert!(terminal.pending_agent_resume_plan.is_none()); + assert!(!terminal.respawn_shell_on_exit); + + let runtime = app + .terminal_runtimes + .get(&terminal_id) + .expect("pending resume should leave a shell runtime"); + let marker = "restored agent: shell quoted | marker"; + for _ in 0..20 { + if runtime + .snapshot_history() + .is_some_and(|text| text.contains(marker)) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + runtime + .snapshot_history() + .expect("runtime should expose terminal history") + .contains(marker), + "deferred restore should inject the resume argv into the restored shell" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_can_launch_after_theme_wait_expires() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() { + let mut app = test_app(); + let active_workspace = crate::workspace::Workspace::test_new("active"); + let active_pane = active_workspace.tabs[0].root_pane; + let active_terminal = active_workspace.terminal_id(active_pane).cloned().unwrap(); + let hidden_workspace = crate::workspace::Workspace::test_new("hidden"); + let hidden_pane = hidden_workspace.tabs[0].root_pane; + let hidden_terminal = hidden_workspace.terminal_id(hidden_pane).cloned().unwrap(); + app.state.view.pane_infos = active_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![active_workspace, hidden_workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + for terminal_id in [&active_terminal, &hidden_terminal] { + app.state + .terminals + .get_mut(terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"), + }); + } + app.pending_agent_resume_deadline = + Some(std::time::Instant::now() - std::time::Duration::from_millis(1)); + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&active_terminal).is_some()); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_none()); + assert!( + app.pending_agent_resume_deadline.is_none(), + "hidden-only pending resumes should not keep an expired wakeup deadline active" + ); + assert!( + app.state + .terminals + .get(&hidden_terminal) + .expect("hidden terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "hidden restored panes should wait until their tab has computed geometry" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() { + let mut app = test_app(); + let previous_workspace = crate::workspace::Workspace::test_new("previous"); + let previous_pane = previous_workspace.tabs[0].root_pane; + let previous_terminal = previous_workspace + .terminal_id(previous_pane) + .cloned() + .unwrap(); + let current_workspace = crate::workspace::Workspace::test_new("current"); + app.state.view.pane_infos = previous_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![previous_workspace, current_workspace]; + app.state.active = Some(1); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&previous_terminal) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(app.pending_agent_resume_deadline.is_none()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&previous_terminal).is_none()); + assert!( + app.state + .terminals + .get(&previous_terminal) + .expect("previous terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "a pane hidden by navigation should wait for a fresh visible geometry snapshot" + ); + } + + #[tokio::test] + async fn pending_agent_resume_launches_with_inner_rect_size() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("split"); + let pane_id = workspace.test_split(ratatui::layout::Direction::Horizontal); + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = vec![crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, 100, 30), + inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), + scrollbar_rect: None, + is_focused: true, + }]; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(app.start_pending_agent_resumes(false)); + assert_eq!( + app.terminal_runtimes + .get(&terminal_id) + .expect("pending resume should launch") + .current_size(), + (28, 98) + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[test] + fn shell_command_from_argv_quotes_resume_arguments() { + let argv = vec![ + "claude".to_string(), + "--resume".to_string(), + "session with ' quote".to_string(), + ]; + + assert_eq!( + shell_command_from_argv(&argv).as_deref(), + Some("claude --resume 'session with '\\'' quote'") + ); + assert_eq!(shell_command_from_argv(&[]), None); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 401a981d..99ae3bf1 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -519,7 +519,7 @@ mod tests { let deadline = app .selection_highlight_clear_deadline .expect("highlight clear deadline"); - assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1))); + assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false)); assert!(app.state.selection.is_none()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0dcd0372..5a39b8e2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ //! - `input.rs` — key/mouse → action translation pub(crate) mod actions; +mod agent_resume; mod agents; mod api; mod api_helpers; @@ -34,6 +35,7 @@ pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); +const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); @@ -105,6 +107,7 @@ pub struct App { pub(crate) next_animation_tick: Option<Instant>, pub(crate) next_auto_update_check: Option<Instant>, pub(crate) agent_metadata_deadline: Option<Instant>, + pub(crate) pending_agent_resume_deadline: Option<Instant>, pub(crate) selection_autoscroll_deadline: Option<Instant>, pub(crate) selection_highlight_clear_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, @@ -576,6 +579,7 @@ impl App { next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), agent_metadata_deadline: None, + pending_agent_resume_deadline: None, session_save_deadline: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, @@ -698,7 +702,7 @@ impl App { self.sync_session_save_schedule(); let now = Instant::now(); - if self.handle_scheduled_tasks(now) { + if self.handle_scheduled_tasks(now, needs_render) { needs_render = true; } @@ -813,6 +817,11 @@ impl App { cell_size, )?; } + self.sync_pending_agent_resume_deadline(now); + if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } self.last_render_at = Some(now); needs_render = false; continue; @@ -3000,7 +3009,7 @@ mod tests { let mut app = test_app(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now()); + app.handle_scheduled_tasks(Instant::now(), false); assert!(app.session_save_deadline.is_none()); } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ad90a267..4ddcde6c 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -165,13 +165,15 @@ impl App { false } - pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { + pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; + let mut resized = false; self.sync_animation_timer(now); if now >= self.next_resize_poll { - changed |= self.handle_resize_poll(); + resized = self.handle_resize_poll(); + changed |= resized; self.next_resize_poll = now + RESIZE_POLL_INTERVAL; } @@ -247,6 +249,12 @@ impl App { changed = true; } + if geometry_dirty || resized { + self.pending_agent_resume_deadline = None; + } else { + self.sync_pending_agent_resume_deadline(now); + changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now)); + } self.sync_animation_timer(now); changed } @@ -496,6 +504,7 @@ impl App { .flatten(), self.next_auto_update_check, self.agent_metadata_deadline, + self.pending_agent_resume_deadline, self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, @@ -914,4 +923,89 @@ mod tests { assert!(app.state.selection_autoscroll.is_none()); assert!(app.selection_autoscroll_deadline.is_none()); } + + #[tokio::test] + async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!( + app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Foreground, + color: crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }, + }) + .await + ); + assert!( + app.terminal_runtimes.get(&terminal_id).is_none(), + "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos" + ); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + } + + #[tokio::test] + async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!app.handle_scheduled_tasks(Instant::now(), true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(app.pending_agent_resume_deadline.is_none()); + } } |
