diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-08-11 02:24:02 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-11 03:24:02 +0300 |
| commit | 7ae4b056a0ca478e584fa282c45b528134cc80c9 (patch) | |
| tree | 5eb8da51353327f2bb1b7ef35f76cafe9b471dee /src | |
| parent | 350f001320ec4a119f09c38157973126962a6121 (diff) | |
fix: wait for agent prompt readiness (#2537)
* fix: wait for agent prompt readiness
refs #2410
* fix: return pane updates after tab bar command
refs #2410
---------
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 139 | ||||
| -rw-r--r-- | src/app/api.rs | 30 | ||||
| -rw-r--r-- | src/cli/agent.rs | 28 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 34 | ||||
| -rw-r--r-- | src/detect/manifests/codex.toml | 17 | ||||
| -rw-r--r-- | src/events.rs | 6 | ||||
| -rw-r--r-- | src/pane.rs | 46 | ||||
| -rw-r--r-- | src/server/headless.rs | 85 | ||||
| -rw-r--r-- | src/terminal/state.rs | 95 |
9 files changed, 414 insertions, 66 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 5d8051fd..d6e264f5 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -177,7 +177,10 @@ pub fn notification_toast_for_pane_state_update( suppress_active_tab_notifications: bool, update: &PaneStateUpdate, ) -> Option<ToastKind> { - if suppress_active_tab_notifications || update.state == update.previous_state { + if update.suppress_completion + || suppress_active_tab_notifications + || update.state == update.previous_state + { return None; } @@ -249,6 +252,7 @@ pub struct PaneStateUpdate { pub agent_name_changed: bool, pub agent_released: bool, pub agent_release_status: Option<crate::api::schema::AgentStatus>, + pub suppress_completion: bool, } // --------------------------------------------------------------------------- @@ -1036,7 +1040,7 @@ impl AppState { .get_mut(&terminal_id)? .expire_agent_metadata_at(scheduled_deadline, now)?; let change = mutation.effective_state_change?; - let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; + let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, false)?; let update = PaneStateUpdate { pane_id, ws_idx, @@ -1053,6 +1057,7 @@ impl AppState { agent_name_changed: false, agent_released: false, agent_release_status: None, + suppress_completion: false, }; Some(update) }) @@ -2771,6 +2776,16 @@ impl AppState { } Vec::new() } + AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at, + } => self + .update_terminal_state(pane_id, |terminal| { + Some(terminal.set_detected_agent_process_at(agent, observed_at)) + }) + .into_iter() + .collect(), AppEvent::StateChanged { pane_id, agent, @@ -2954,11 +2969,20 @@ impl AppState { .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; let now = Instant::now(); - let (mutation, managed_changed, agent_name_changed, unchanged_change) = { + let ( + mutation, + managed_changed, + agent_name_changed, + unchanged_change, + managed_launch_pending, + suppress_acquisition_completion, + ) = { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); + let managed_launch_pending = terminal.managed_agent_launch_pending(); let mutation = update(terminal)?; let managed_changed = terminal.reconcile_managed_agent_at(now, false); + let suppress_acquisition_completion = terminal.finish_agent_process_acquisition(); let agent_name_changed = terminal.agent_name != previous_agent_name; let unchanged_change = (mutation.agent_released || agent_name_changed) .then(|| terminal.unchanged_effective_state_change_at(now)); @@ -2967,6 +2991,8 @@ impl AppState { managed_changed, agent_name_changed, unchanged_change, + managed_launch_pending, + suppress_acquisition_completion, ) }; if mutation.session_ref_changed || managed_changed || agent_name_changed { @@ -2974,13 +3000,15 @@ impl AppState { } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; + let suppress_completion = change.state == AgentState::Idle + && (managed_launch_pending || suppress_acquisition_completion); if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); } } - let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; + let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, suppress_completion)?; let update = PaneStateUpdate { pane_id, ws_idx, @@ -3005,6 +3033,7 @@ impl AppState { agent_name_changed, agent_released, agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), + suppress_completion, }; Some(update) } @@ -3071,6 +3100,7 @@ impl AppState { ws_idx: usize, pane_id: PaneId, change: &EffectiveStateChange, + suppress_completion: bool, ) -> Option<bool> { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let suppress_active_tab_notifications = @@ -3082,13 +3112,17 @@ impl AppState { if change.state != AgentState::Idle { pane.seen = true; - } else if is_completion_transition(change) { + } else if !suppress_completion && is_completion_transition(change) { pane.seen = suppress_active_tab_notifications; } let seen = pane.seen; - if let Some(delivery) = self.record_or_deliver_agent_notification(ws_idx, pane_id, change) { - self.apply_agent_notification_delivery(&delivery); + if !suppress_completion { + if let Some(delivery) = + self.record_or_deliver_agent_notification(ws_idx, pane_id, change) + { + self.apply_agent_notification_delivery(&delivery); + } } Some(seen) @@ -4965,6 +4999,97 @@ mod tests { } #[test] + fn first_idle_after_process_detection_is_not_completion() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(0); + let pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + let direct_idle = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + .pop() + .expect("direct idle state update"); + assert!(direct_idle.suppress_completion); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + for agent_state in [AgentState::Working, AgentState::Blocked] { + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: agent_state, + visible_blocker: agent_state == AgentState::Blocked, + visible_working: agent_state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let update = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + .pop() + .expect("idle state update"); + + assert!(update.suppress_completion); + assert!(state.workspaces[1].panes[&pane_id].seen); + assert!(!matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Codex, + observed_at: Instant::now(), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_working: true, + process_exited: false, + observed_at: Instant::now(), + }); + let exit_update = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: Instant::now(), + }) + .pop() + .expect("process exit update"); + assert!(!exit_update.suppress_completion); + } + + #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), diff --git a/src/app/api.rs b/src/app/api.rs index 18471f57..92d9596f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -106,13 +106,20 @@ impl App { } pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { + let _ = self.handle_internal_event_with_pane_updates(ev); + } + + pub(crate) fn handle_internal_event_with_pane_updates( + &mut self, + ev: AppEvent, + ) -> Vec<crate::app::actions::PaneStateUpdate> { if let AppEvent::TerminalBell { count, .. } = ev { if let Err(err) = crate::terminal_effects::write_terminal_bells(&mut std::io::stdout(), count) { tracing::warn!(err = %err, "failed to emit terminal bell"); } - return; + return Vec::new(); } if let AppEvent::ClipboardWrite { content } = ev { @@ -121,7 +128,7 @@ impl App { #[cfg(test)] let _ = content; self.show_clipboard_feedback(); - return; + return Vec::new(); } if let AppEvent::PrefixInputSource { active } = ev { @@ -130,14 +137,14 @@ impl App { // App-internal drain consume the event before the forwarding drain, the flag keeps the // switch out of the headless server process. if !self.local_input_source_switch { - return; + return Vec::new(); } if active { self.prefix_input_source.switch_to_ascii(); } else { self.prefix_input_source.restore(); } - return; + return Vec::new(); } if let AppEvent::GitStatusRefreshed { @@ -146,7 +153,7 @@ impl App { } = ev { self.handle_git_status_refreshed(results, cache_updates); - return; + return Vec::new(); } if let AppEvent::TabBarCommandFinished { @@ -156,7 +163,7 @@ impl App { } = ev { let _ = self.handle_tab_bar_command_finished(generation, segment_index, result); - return; + return Vec::new(); } if let AppEvent::PluginCommandFinished { @@ -187,17 +194,17 @@ impl App { crate::api::schema::PluginCommandStatus::Failed }; } - return; + return Vec::new(); } if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_worktree_add_finished(*result); - return; + return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { self.handle_worktree_remove_finished(*result); - return; + return Vec::new(); } if let AppEvent::PaneDied { pane_id } = &ev { @@ -208,7 +215,7 @@ impl App { .is_some_and(|popup| popup.pane_id == *pane_id) { self.close_popup_pane(); - return; + return Vec::new(); } let previous_toast = self.state.toast.clone(); if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { @@ -223,7 +230,7 @@ impl App { self.overlay_panes.remove(pane_id); self.render_dirty.request_generic(); self.render_notify.notify_one(); - return; + return Vec::new(); } } @@ -368,6 +375,7 @@ impl App { self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); + pane_updates } fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) { diff --git a/src/cli/agent.rs b/src/cli/agent.rs index e1271f48..362c9ab1 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -543,16 +543,26 @@ fn wait_for_named_agent( ))) } else if agent["name"].as_str() != Some(name) { Some(Err(agent_name_lost_error("cli:agent:start", name))) - } else if agent["interactive_ready"].as_bool().unwrap_or(false) { - Some(Ok(agent.clone())) - } else if !agent["launch_pending"].as_bool().unwrap_or(false) { - Some(Err(cli_agent_error( - "cli:agent:start", - "agent_start_failed", - "agent process exited before becoming interactive", - ))) } else { - None + match agent["agent_status"].as_str() { + Some("blocked") => Some(Err(cli_agent_error( + "cli:agent:start", + "agent_not_ready", + format!("agent {name} is blocked during startup and is not ready for prompts"), + ))), + Some("working" | "unknown") => None, + Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => { + Some(Ok(agent.clone())) + } + Some("idle" | "done") if !agent["launch_pending"].as_bool().unwrap_or(false) => { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent process exited before becoming interactive", + ))) + } + _ => None, + } }; if let Some(outcome) = outcome { return Ok(outcome); diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 11c92f49..265dfa69 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -734,6 +734,40 @@ fn codex_osc_title_plain_is_idle() { } #[test] +fn codex_trust_directory_requires_live_top_region() { + let screen = "> You are in C:\\Users\\user\\project\n\n\ + Do you trust the contents of this\n\ + directory? Working with untrusted\n\ + contents comes with higher risk of\n\ + prompt injection. Trusting the\n\ + directory allows project-local config,\n\ + hooks, and exec policies to load.\n\n\ + › 1. Yes, continue\n\ + 2. No, quit\n\n\ + Press enter to continue\n"; + let result = osc_explain(Agent::Codex, screen, "project", ""); + + assert_eq!(result.state, AgentState::Blocked); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("trust_directory") + ); + assert!(result.visible_blocker); + + let transcript = "› > You are in C:\\Users\\user\\project\n\n\ + Do you trust the contents of this\n\ + directory? Working with untrusted contents comes with higher risk.\n"; + let result = osc_explain(Agent::Codex, transcript, "project", ""); + + assert_eq!(result.state, AgentState::Idle); + assert_ne!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("trust_directory") + ); + assert!(!result.visible_blocker); +} + +#[test] fn codex_background_terminal_screen_does_not_override_osc_idle() { // Background terminal tasks can be long-lived helpers such as dev servers. // They should not make Codex look busy once the foreground turn is idle. diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index a4a4ca9f..9169e108 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.07.18.1" -min_engine_version = 2 -updated_at = "2026-07-18T00:00:00Z" +version = "2026.08.09.1" +min_engine_version = 3 +updated_at = "2026-08-09T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -32,6 +32,17 @@ any = [ ] [[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, +] + +[[rules]] id = "live_strong_blocker" state = "blocked" priority = 900 diff --git a/src/events.rs b/src/events.rs index 65f1001a..cc638e60 100644 --- a/src/events.rs +++ b/src/events.rs @@ -56,6 +56,12 @@ pub struct WorktreeRemoveResult { pub enum AppEvent { /// A pane's child process exited. PaneDied { pane_id: PaneId }, + /// Process detection identified an agent before its screen state was confirmed. + AgentProcessDetected { + pane_id: PaneId, + agent: Agent, + observed_at: Instant, + }, /// Fallback detector state changed in a pane. StateChanged { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index 1ade096e..c95da331 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -216,6 +216,28 @@ async fn publish_state_changed_event( } } +async fn publish_agent_process_detected_event( + state_events: mpsc::Sender<AppEvent>, + pane_id: PaneId, + agent: Agent, + observed_at: std::time::Instant, +) { + if let Err(e) = state_events + .send(AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at, + }) + .await + { + warn!( + pane = pane_id.raw(), + err = %e, + "failed to deliver AgentProcessDetected event" + ); + } +} + #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { state: AgentState, @@ -792,21 +814,17 @@ fn spawn_basic_detection_task( // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); - if agent.is_some() { + if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - state = AgentState::Idle; - last_visible_idle = true; + state = AgentState::Unknown; + last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_state_changed_event( + publish_agent_process_detected_event( state_events.clone(), pane_id, agent, - AgentState::Idle, - false, - false, - false, now, ) .await; @@ -2293,22 +2311,18 @@ impl PaneRuntime { // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); - if agent.is_some() { + if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - state = AgentState::Idle; - last_visible_idle = true; + state = AgentState::Unknown; + last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_state_changed_event( + publish_agent_process_detected_event( state_events.clone(), pane_id, agent, - AgentState::Idle, - false, - false, - false, now, ) .await; diff --git a/src/server/headless.rs b/src/server/headless.rs index 3425225c..ff6f367d 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1963,7 +1963,7 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); - if self.app.state.sound.allows(update.known_agent) { + if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, @@ -2325,7 +2325,11 @@ impl HeadlessServer { // Headless mode disables local sound playback separately from the // sound policy so reloads can keep server-side notification policy live. self.sync_foreground_client_state(); - self.app.handle_internal_event(ev); + let suppress_completion = self + .app + .handle_internal_event_with_pane_updates(ev) + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); // Forward sound notification to clients when server-side sound policy allows it. let is_active_tab = self @@ -2344,7 +2348,8 @@ impl HeadlessServer { let next_state = self.pane_effective_state(pane_id_val); let next_agent_label = self.pane_effective_agent_label(pane_id_val); - if self.app.state.toast_config.delay_seconds == 0 + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = @@ -2364,7 +2369,8 @@ impl HeadlessServer { } } - let toast_msg = if self.app.state.toast_config.delay_seconds == 0 + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { @@ -2416,7 +2422,11 @@ impl HeadlessServer { let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); - self.app.handle_internal_event(ev); + let suppress_completion = self + .app + .handle_internal_event_with_pane_updates(ev) + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); // Forward sound notification based on the effective transition when // server-side sound policy allows it. @@ -2436,7 +2446,8 @@ impl HeadlessServer { let next_state = self.pane_effective_state(pane_id_val); let next_agent_label = self.pane_effective_agent_label(pane_id_val); - if self.app.state.toast_config.delay_seconds == 0 + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = @@ -2456,7 +2467,8 @@ impl HeadlessServer { } } - let toast_msg = if self.app.state.toast_config.delay_seconds == 0 + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { @@ -11128,6 +11140,65 @@ next_tab = "" } #[test] + fn startup_idle_does_not_forward_completion() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("active"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + server.app.state.toast_config.delay_seconds = 0; + server.app.state.sound.enabled = true; + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }) + ); + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(false), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + while client_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "startup readiness should not forward a completion notification" + ); + } + + #[test] fn delayed_agent_notification_forwards_after_deadline() { let mut server = test_headless_server(); let background = crate::workspace::Workspace::test_new("background"); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 56064a94..4b1fdaef 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -65,6 +65,7 @@ enum ManagedAgentPhase { deadline: Instant, observed_expected: bool, }, + Blocked, Active, } @@ -144,6 +145,7 @@ pub struct TerminalState { pub launch_argv: Option<Vec<String>>, pub respawn_shell_on_exit: bool, recent_agent_process_exit: Option<RecentAgentProcessExit>, + agent_process_acquisition_pending: bool, pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>, } @@ -177,10 +179,43 @@ impl TerminalState { launch_argv: None, respawn_shell_on_exit: false, recent_agent_process_exit: None, + agent_process_acquisition_pending: false, pending_agent_resume_plan: None, } } + pub fn set_detected_agent_process_at( + &mut self, + agent: Agent, + now: Instant, + ) -> TerminalStateMutation { + let starts_acquisition = !self + .should_ignore_detected_state_under_full_lifecycle_hook(Some(agent), false) + && !self.detected_state_observed_before_release_suppression(Some(agent), now); + let mutation = self.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Unknown, + false, + false, + false, + false, + now, + ); + if starts_acquisition { + self.agent_process_acquisition_pending = true; + } + mutation + } + + pub(crate) fn finish_agent_process_acquisition(&mut self) -> bool { + let reached_idle = self.agent_process_acquisition_pending && self.state == AgentState::Idle; + let suppress_completion = reached_idle && self.recent_agent_process_exit.is_none(); + if reached_idle { + self.agent_process_acquisition_pending = false; + } + suppress_completion + } + pub(crate) fn terminal_title_stripped(&self) -> Option<String> { self.terminal_title .as_deref() @@ -1877,8 +1912,12 @@ impl TerminalState { } pub fn managed_agent_launch_pending(&self) -> bool { - self.managed_agent - .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) + self.managed_agent.is_some_and(|managed| { + matches!( + managed.phase, + ManagedAgentPhase::Pending { .. } | ManagedAgentPhase::Blocked + ) + }) } pub fn managed_agent_interactive_ready(&self) -> bool { @@ -1911,7 +1950,7 @@ impl TerminalState { ManagedAgentPhase::Pending { observed_expected, .. } => observed_expected || known_agent == Some(managed.kind), - ManagedAgentPhase::Active => false, + ManagedAgentPhase::Blocked | ManagedAgentPhase::Active => false, }; let clear = process_exited || known_agent.is_some_and(|agent| agent != managed.kind) @@ -1922,20 +1961,35 @@ impl TerminalState { self.clear_agent_name(); return true; } + if managed.phase == ManagedAgentPhase::Blocked { + if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + phase: ManagedAgentPhase::Active, + }); + return true; + } + return false; + } if let ManagedAgentPhase::Pending { ready_after, deadline, observed_expected: previous_observed_expected, } = managed.phase { + if known_agent == Some(managed.kind) && self.state == AgentState::Blocked { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + phase: ManagedAgentPhase::Blocked, + }); + return true; + } if now >= deadline { self.clear_agent_name(); return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { - if known_agent == Some(managed.kind) - && matches!(self.state, AgentState::Idle | AgentState::Blocked) - { + if known_agent == Some(managed.kind) && self.state == AgentState::Idle { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -2003,6 +2057,7 @@ impl TerminalState { self.launch_argv = None; self.respawn_shell_on_exit = false; self.recent_agent_process_exit = None; + self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -2143,7 +2198,7 @@ mod tests { } #[test] - fn managed_agent_activates_only_after_matching_settled_detection() { + fn managed_agent_readiness_tracks_detection_state() { let mut terminal = test_terminal(); let now = Instant::now(); terminal.begin_managed_agent( @@ -2153,23 +2208,35 @@ mod tests { Duration::from_millis(100), Duration::from_secs(1), ); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Unknown); assert!(terminal.managed_agent_launch_pending()); assert!(!terminal.managed_agent_interactive_ready()); assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); - assert!(!terminal.managed_agent_launch_pending()); - assert!(terminal.managed_agent_interactive_ready()); - assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.managed_agent_launch_pending()); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + + terminal.set_detected_state(Some(Agent::Pi), AgentState::Blocked); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), false)); + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); + assert!(!terminal.managed_agent_launch_pending()); assert!(terminal.managed_agent_interactive_ready()); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.managed_agent_interactive_ready()); - assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); - assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), true)); assert_eq!(terminal.agent_name, None); } @@ -5529,6 +5596,7 @@ mod tests { session_ref: crate::agent_resume::AgentSessionRef::id("codex-session").unwrap(), }); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_detected_agent_process_at(Agent::Codex, Instant::now()); terminal.clear_agent_runtime_identity_after_respawn(); @@ -5537,6 +5605,7 @@ mod tests { assert!(terminal.agent_name.is_none()); assert!(terminal.persisted_agent_session.is_none()); assert!(!terminal.respawn_shell_on_exit); + assert!(!terminal.finish_agent_process_acquisition()); } #[test] |
