aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-08-11 02:24:02 +0200
committerGitHub <noreply@github.com>2026-08-11 03:24:02 +0300
commit7ae4b056a0ca478e584fa282c45b528134cc80c9 (patch)
tree5eb8da51353327f2bb1b7ef35f76cafe9b471dee /src
parent350f001320ec4a119f09c38157973126962a6121 (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.rs139
-rw-r--r--src/app/api.rs30
-rw-r--r--src/cli/agent.rs28
-rw-r--r--src/detect/manifest/tests.rs34
-rw-r--r--src/detect/manifests/codex.toml17
-rw-r--r--src/events.rs6
-rw-r--r--src/pane.rs46
-rw-r--r--src/server/headless.rs85
-rw-r--r--src/terminal/state.rs95
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]