From 6fecdee48d472ade60c0f56bcdcb1e902411e82a Mon Sep 17 00:00:00 2001 From: Sohum Gaitonde Date: Thu, 1 Oct 2026 02:00:44 -0400 Subject: [PATCH 1/4] Handle CLI agent needs-input in unattended runs Recognize the agent_needs_input CLI agent event as NeedsInput and, in autonomous (CLI/daemon) execution modes only, report the session as Blocked with a fixed message so the task is recorded as BLOCKED instead of staying in progress. Interactive clients keep ignoring the event. When the exit timer fires for a session blocked on NeedsInput, skip the typed /exit and follow-up Enter, which would answer whatever prompt is open, and go straight to the existing force-kill. Other statuses keep the graceful exit ladder. Co-Authored-By: Warp --- app/src/ai/agent_sdk/driver.rs | 34 ++++- .../driver/harness/exit_escalation.rs | 17 ++- .../driver/harness/exit_escalation_tests.rs | 26 ++++ .../terminal/cli_agent_sessions/event/mod.rs | 2 + .../terminal/cli_agent_sessions/event/v1.rs | 1 + app/src/terminal/cli_agent_sessions/mod.rs | 30 ++++- .../terminal/cli_agent_sessions/mod_tests.rs | 126 ++++++++++++++++++ 7 files changed, 230 insertions(+), 6 deletions(-) diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 22afc267e30..929e44f608f 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -3217,10 +3217,15 @@ impl AgentDriver { .context("Failed to enqueue periodic harness conversation save")); } _ = harness_exit_rx => { + let start_event = if Self::session_blocked_on_needs_input(foreground).await { + ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput + } else { + ExitEscalationEvent::ShutdownRequested + }; break Self::escalate_harness_exit( runner.as_ref(), &harness_name, - ExitEscalationEvent::ShutdownRequested, + start_event, &mut command_handle, foreground, ) @@ -3376,8 +3381,10 @@ impl AgentDriver { let mut escalation = ExitEscalation::new(); match escalation.on_event(start_event) { ExitEscalationAction::SendExit => {} + ExitEscalationAction::ForceKillAndFinish => { + return Self::force_kill_and_report_timeout(harness_name, 1, foreground).await; + } ExitEscalationAction::SendFollowup - | ExitEscalationAction::ForceKillAndFinish | ExitEscalationAction::Finish | ExitEscalationAction::Ignore => { log::error!( @@ -3451,9 +3458,17 @@ impl AgentDriver { } } + Self::force_kill_and_report_timeout(harness_name, 3, foreground).await + } + + async fn force_kill_and_report_timeout( + harness_name: &str, + attempt: u8, + foreground: &ModelSpawner, + ) -> Result { log::warn!( "Ambient agent CLI lifecycle: event=harness_exit_attempt \ - harness={harness_name} attempt=3 method=force_kill" + harness={harness_name} attempt={attempt} method=force_kill" ); Self::send_harness_exit_telemetry(harness_name, "force_kill", foreground).await; Self::force_kill_harness(foreground).await; @@ -3462,6 +3477,19 @@ impl AgentDriver { }) } + async fn session_blocked_on_needs_input(foreground: &ModelSpawner) -> bool { + foreground + .spawn(|me, ctx| { + let view_id = me.terminal_driver.as_ref(ctx).terminal_view().id(); + CLIAgentSessionsModel::handle(ctx) + .as_ref(ctx) + .session(view_id) + .is_some_and(|session| session.is_blocked_on_needs_input()) + }) + .await + .unwrap_or(false) + } + /// Force-kills the harness and attempts a final conversation save after a sandbox-deadline /// or signal interruption. Dropping `run_internal` for either cause bypasses /// `run_harness`'s ordinary final-save path, so this recovers it best-effort. diff --git a/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs b/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs index 4543a184427..ed7b7dcf5b5 100644 --- a/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs +++ b/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs @@ -1,6 +1,7 @@ //! Bounded shutdown ladder for a third-party harness: `/exit`, a follow-up //! Enter, then a best-effort force-kill. Completes without waiting to prove -//! the process exited. +//! the process exited. A harness waiting on user input skips straight to the +//! force-kill, since typed text would answer whatever prompt is open. use super::super::AgentDriverError; @@ -19,6 +20,8 @@ pub(crate) enum ExitEscalationEvent { CommandExited, /// CLI session reached a terminal state and asked the driver to stop the harness. ShutdownRequested, + /// Same request, but the CLI session is blocked on user input. + ShutdownRequestedWhileAwaitingInput, /// Runtime-failure scanner confirmed a hang/auth failure and asked the driver to stop. ScannerDetected, FollowupDeadlineElapsed, @@ -71,6 +74,13 @@ impl ExitEscalation { self.phase = ExitEscalationPhase::AwaitingGracefulExit; ExitEscalationAction::SendExit } + ( + ExitEscalationPhase::Running, + ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput, + ) => { + self.phase = ExitEscalationPhase::Done; + ExitEscalationAction::ForceKillAndFinish + } ( ExitEscalationPhase::AwaitingGracefulExit, ExitEscalationEvent::FollowupDeadlineElapsed, @@ -89,13 +99,16 @@ impl ExitEscalation { ExitEscalationPhase::Done, ExitEscalationEvent::CommandExited | ExitEscalationEvent::ShutdownRequested + | ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput | ExitEscalationEvent::ScannerDetected | ExitEscalationEvent::FollowupDeadlineElapsed | ExitEscalationEvent::ForceKillDeadlineElapsed, ) | ( ExitEscalationPhase::AwaitingGracefulExit | ExitEscalationPhase::AwaitingFollowup, - ExitEscalationEvent::ShutdownRequested | ExitEscalationEvent::ScannerDetected, + ExitEscalationEvent::ShutdownRequested + | ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput + | ExitEscalationEvent::ScannerDetected, ) | ( ExitEscalationPhase::Running, diff --git a/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs b/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs index c21641948ae..ee99fd56e59 100644 --- a/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs +++ b/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs @@ -100,6 +100,32 @@ fn scanner_detection_during_an_in_flight_ladder_does_not_restart_it() { ); } +#[test] +fn shutdown_while_awaiting_input_force_kills_without_sending_any_input() { + let (escalation, actions) = + actions_for(&[ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput]); + + assert_eq!(actions, vec![ExitEscalationAction::ForceKillAndFinish]); + assert_eq!(escalation.phase(), ExitEscalationPhase::Done); +} + +#[test] +fn shutdown_while_awaiting_input_does_not_restart_an_in_flight_ladder() { + let mut escalation = ExitEscalation::new(); + assert_eq!( + escalation.on_event(ExitEscalationEvent::ShutdownRequested), + ExitEscalationAction::SendExit + ); + assert_eq!( + escalation.on_event(ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput), + ExitEscalationAction::Ignore + ); + assert_eq!( + escalation.phase(), + ExitEscalationPhase::AwaitingGracefulExit + ); +} + #[test] fn bounded_timeout_is_not_a_driver_error_and_is_still_err_before_mapping() { let timeout = Err(AgentDriverError::HarnessExitTimedOut { diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index 22bdcaeeed1..ffcc219b0f3 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -20,6 +20,8 @@ pub enum CLIAgentEventType { PermissionRequest, PermissionReplied, QuestionAsked, + /// The agent cannot continue until a person responds. + NeedsInput, IdlePrompt, Unknown(String), } diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index 7638165e143..e16499431a4 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -21,6 +21,7 @@ pub(super) fn parse(body: &str) -> Option { "permission_request" => CLIAgentEventType::PermissionRequest, "permission_replied" => CLIAgentEventType::PermissionReplied, "question_asked" => CLIAgentEventType::QuestionAsked, + "agent_needs_input" => CLIAgentEventType::NeedsInput, "idle_prompt" => CLIAgentEventType::IdlePrompt, other => CLIAgentEventType::Unknown(other.to_string()), }; diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 5475fb05a4c..66beee8c34e 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType}; +use warp_core::execution_mode::AppExecutionMode; use warpui::r#async::SpawnedFutureHandle; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; @@ -19,6 +20,8 @@ use crate::ai::blocklist::InputConfig; /// interrupt silently cancelled the session. See `observe_ctrl_c_write`. pub const CTRL_C_CANCEL_WINDOW: Duration = Duration::from_secs(2); +const NEEDS_INPUT_STATUS_MESSAGE: &str = "Agent requires user input"; + /// Status of a tracked CLI agent session. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CLIAgentSessionStatus { @@ -64,6 +67,8 @@ pub struct CLIAgentSessionContext { pub summary: Option, pub query: Option, pub response: Option, + /// Whether the latest status change was a `NeedsInput` event. + pub blocked_on_needs_input: bool, } /// State of the rich input editor for composing a prompt to send to a CLI agent. @@ -181,6 +186,13 @@ impl CLIAgentSession { self.received_rich_notification } + /// Whether the session is waiting on input the agent could not describe, so nothing + /// should be typed into its terminal. + pub fn is_blocked_on_needs_input(&self) -> bool { + matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) + && self.session_context.blocked_on_needs_input + } + /// Clears state populated by `PermissionRequest`. Called whenever the /// session leaves the permission flow (the user replied, a blocking tool /// completed, a new prompt is submitted, or the session ends successfully) @@ -250,6 +262,11 @@ impl CLIAgentSession { .clone() .or_else(|| Some("Waiting for your answer".to_owned())), }, + // The notification text is deliberately dropped: it can contain arbitrary dialog + // content and becomes the task status message reported to the server. + CLIAgentEventType::NeedsInput => CLIAgentSessionStatus::Blocked { + message: Some(NEEDS_INPUT_STATUS_MESSAGE.to_owned()), + }, CLIAgentEventType::PermissionReplied => { if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) { return None; @@ -267,6 +284,8 @@ impl CLIAgentSession { CLIAgentEventType::Unknown(_) => return None, }; + self.session_context.blocked_on_needs_input = + matches!(event.event, CLIAgentEventType::NeedsInput); self.status = new_status.clone(); Some(new_status) } @@ -511,7 +530,16 @@ impl CLIAgentSessionsModel { } let event_type = &event.event; - if let Some(new_status) = session.apply_event(event) { + // Interactive clients keep ignoring this event: only an unattended run has no one to + // answer the prompt. + let new_status = if matches!(event.event, CLIAgentEventType::NeedsInput) + && !AppExecutionMode::as_ref(ctx).is_autonomous() + { + None + } else { + session.apply_event(event) + }; + if let Some(new_status) = new_status { let agent = session.agent; ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { terminal_view_id, diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 76ba27c861a..e24b7ce101b 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::r#async::Timer; use warpui::{App, EntityId}; @@ -112,6 +113,14 @@ fn parse_idle_prompt_notification() { ); } +#[test] +fn parse_agent_needs_input_notification() { + let body = r#"{"v":1,"agent":"claude","event":"agent_needs_input","session_id":"abc","cwd":"/tmp","project":"tmp","summary":"Some dialog text"}"#; + let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); + + assert_eq!(notif.event, CLIAgentEventType::NeedsInput); +} + #[test] fn parse_session_start_notification() { let body = r#"{"v":1,"agent":"claude","event":"session_start","session_id":"abc","cwd":"/tmp","project":"tmp","plugin_version":"1.1.0"}"#; @@ -703,6 +712,117 @@ fn permission_request_still_populates_summary_and_tool_fields() { )); } +#[test] +fn needs_input_blocks_session_with_generic_message() { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + let event = CLIAgentEvent { + payload: CLIAgentEventPayload { + summary: Some("Arbitrary dialog text that must not be reported".to_owned()), + ..Default::default() + }, + ..rich_event(CLIAgentEventType::NeedsInput) + }; + + let new_status = session.apply_event(&event); + + let expected = CLIAgentSessionStatus::Blocked { + message: Some("Agent requires user input".to_owned()), + }; + assert_eq!(new_status, Some(expected.clone())); + assert_eq!(session.status, expected); + assert!(session.is_blocked_on_needs_input()); +} + +#[test] +fn other_blocked_events_are_not_blocked_on_needs_input() { + for event in [ + CLIAgentEventType::PermissionRequest, + CLIAgentEventType::QuestionAsked, + ] { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + session.apply_event(&rich_event(event)); + + assert!(matches!( + session.status, + CLIAgentSessionStatus::Blocked { .. } + )); + assert!(!session.is_blocked_on_needs_input()); + } +} + +#[test] +fn permission_request_after_needs_input_is_no_longer_blocked_on_needs_input() { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + session.apply_event(&rich_event(CLIAgentEventType::NeedsInput)); + + session.apply_event(&rich_event(CLIAgentEventType::PermissionRequest)); + + assert!(!session.is_blocked_on_needs_input()); +} + +#[test] +fn tool_complete_after_needs_input_returns_session_to_in_progress() { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + session.apply_event(&rich_event(CLIAgentEventType::NeedsInput)); + + session.apply_event(&rich_event(CLIAgentEventType::ToolComplete)); + + assert_eq!(session.status, CLIAgentSessionStatus::InProgress); + assert!(!session.is_blocked_on_needs_input()); +} + +#[test] +fn idle_prompt_after_needs_input_keeps_session_blocked() { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + session.apply_event(&rich_event(CLIAgentEventType::NeedsInput)); + + let new_status = session.apply_event(&rich_event(CLIAgentEventType::IdlePrompt)); + + assert_eq!(new_status, None); + assert!(matches!( + session.status, + CLIAgentSessionStatus::Blocked { .. } + )); + assert!(session.is_blocked_on_needs_input()); +} + +fn assert_needs_input_blocks_session_in_mode(mode: ExecutionMode, expect_blocked: bool) { + App::test((), |mut app| async move { + app.add_singleton_model(|ctx| AppExecutionMode::new(mode, false, ctx)); + let model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + let view_id = EntityId::new(); + model.update(&mut app, |m, ctx| { + m.set_session( + view_id, + cli_agent_session(CLIAgentSessionStatus::InProgress, true), + ctx, + ); + }); + + model.update(&mut app, |m, ctx| { + m.update_from_event(view_id, &rich_event(CLIAgentEventType::NeedsInput), ctx); + }); + + let status = model.read(&app, |m, _| m.session(view_id).map(|s| s.status.clone())); + assert_eq!( + matches!(status, Some(CLIAgentSessionStatus::Blocked { .. })), + expect_blocked + ); + }); +} + +#[test] +fn needs_input_blocks_autonomous_sessions() { + assert_needs_input_blocks_session_in_mode(ExecutionMode::Sdk, true); +} + +#[test] +fn needs_input_is_ignored_in_the_desktop_app() { + assert_needs_input_blocks_session_in_mode(ExecutionMode::App, false); +} + // --- Ctrl-C pending-cancel state machine --- /// Grace window used by the tests below. Long enough to comfortably @@ -934,6 +1054,7 @@ fn window_lapse_transitions_session_to_cancelled() { /// longer resolves to `Cancelled` once it would have lapsed. fn assert_event_disarms_pending_cancel(disarming_event: CLIAgentEvent) { App::test((), |mut app| async move { + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::Sdk, false, ctx)); let model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); let view_id = EntityId::new(); model.update(&mut app, |m, ctx| { @@ -992,6 +1113,11 @@ fn question_asked_event_disarms_pending_cancel() { assert_event_disarms_pending_cancel(rich_event(CLIAgentEventType::QuestionAsked)); } +#[test] +fn needs_input_event_disarms_pending_cancel() { + assert_event_disarms_pending_cancel(rich_event(CLIAgentEventType::NeedsInput)); +} + #[test] fn prompt_submit_event_disarms_pending_cancel() { assert_event_disarms_pending_cancel(rich_event(CLIAgentEventType::PromptSubmit)); From a792c450d3cd881dbc149674b0778bc3446b6718 Mon Sep 17 00:00:00 2001 From: Sohum Gaitonde Date: Thu, 1 Oct 2026 14:23:03 -0400 Subject: [PATCH 2/4] Forward the needs-input summary in the blocked status Report the NeedsInput event's summary as the Blocked message, falling back to the generic message when there is none, matching how QuestionAsked is reported. The plugin bounds the summary length. Co-Authored-By: Warp --- .../terminal/cli_agent_sessions/event/mod.rs | 1 - app/src/terminal/cli_agent_sessions/mod.rs | 12 ++++++----- .../terminal/cli_agent_sessions/mod_tests.rs | 20 ++++++++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index ffcc219b0f3..e1fbf4d1635 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -20,7 +20,6 @@ pub enum CLIAgentEventType { PermissionRequest, PermissionReplied, QuestionAsked, - /// The agent cannot continue until a person responds. NeedsInput, IdlePrompt, Unknown(String), diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 66beee8c34e..702f987b713 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -186,8 +186,8 @@ impl CLIAgentSession { self.received_rich_notification } - /// Whether the session is waiting on input the agent could not describe, so nothing - /// should be typed into its terminal. + /// Whether the session is blocked on a `NeedsInput` notification, so nothing should be + /// typed into its terminal. pub fn is_blocked_on_needs_input(&self) -> bool { matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) && self.session_context.blocked_on_needs_input @@ -262,10 +262,12 @@ impl CLIAgentSession { .clone() .or_else(|| Some("Waiting for your answer".to_owned())), }, - // The notification text is deliberately dropped: it can contain arbitrary dialog - // content and becomes the task status message reported to the server. CLIAgentEventType::NeedsInput => CLIAgentSessionStatus::Blocked { - message: Some(NEEDS_INPUT_STATUS_MESSAGE.to_owned()), + message: event + .payload + .summary + .clone() + .or_else(|| Some(NEEDS_INPUT_STATUS_MESSAGE.to_owned())), }, CLIAgentEventType::PermissionReplied => { if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) { diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index e24b7ce101b..8994c5dbb7f 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -119,6 +119,7 @@ fn parse_agent_needs_input_notification() { let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::NeedsInput); + assert_eq!(notif.payload.summary.as_deref(), Some("Some dialog text")); } #[test] @@ -713,12 +714,12 @@ fn permission_request_still_populates_summary_and_tool_fields() { } #[test] -fn needs_input_blocks_session_with_generic_message() { +fn needs_input_blocks_session_with_the_event_summary() { let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); let event = CLIAgentEvent { payload: CLIAgentEventPayload { - summary: Some("Arbitrary dialog text that must not be reported".to_owned()), + summary: Some("Some dialog text".to_owned()), ..Default::default() }, ..rich_event(CLIAgentEventType::NeedsInput) @@ -727,13 +728,26 @@ fn needs_input_blocks_session_with_generic_message() { let new_status = session.apply_event(&event); let expected = CLIAgentSessionStatus::Blocked { - message: Some("Agent requires user input".to_owned()), + message: Some("Some dialog text".to_owned()), }; assert_eq!(new_status, Some(expected.clone())); assert_eq!(session.status, expected); assert!(session.is_blocked_on_needs_input()); } +#[test] +fn needs_input_without_a_summary_blocks_session_with_the_generic_message() { + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + let new_status = session.apply_event(&rich_event(CLIAgentEventType::NeedsInput)); + + let expected = CLIAgentSessionStatus::Blocked { + message: Some("Agent requires user input".to_owned()), + }; + assert_eq!(new_status, Some(expected)); + assert!(session.is_blocked_on_needs_input()); +} + #[test] fn other_blocked_events_are_not_blocked_on_needs_input() { for event in [ From 9365a8869fe19c98ce78e29240a44abc986a9ab6 Mon Sep 17 00:00:00 2001 From: Sohum Gaitonde Date: Thu, 1 Oct 2026 16:51:08 -0400 Subject: [PATCH 3/4] Allow dead code for is_blocked_on_needs_input on wasm target --- app/src/terminal/cli_agent_sessions/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 702f987b713..edd9fc85a2c 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -188,6 +188,7 @@ impl CLIAgentSession { /// Whether the session is blocked on a `NeedsInput` notification, so nothing should be /// typed into its terminal. + #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn is_blocked_on_needs_input(&self) -> bool { matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) && self.session_context.blocked_on_needs_input From 1472209d00b4e975606211c688b221a7d6474672 Mon Sep 17 00:00:00 2001 From: Sohum Gaitonde Date: Mon, 5 Oct 2026 10:54:37 -0400 Subject: [PATCH 4/4] Address review feedback on needs-input handling - Carry the awaiting-input signal as a bool on ExitEscalationEvent::ShutdownRequested instead of a separate variant. - Record why a session is blocked on CLIAgentSessionStatus::Blocked via a BlockedSource enum, replacing the blocked_on_needs_input flag on the session context. Co-Authored-By: Warp --- .../agent_management_model.rs | 2 +- app/src/ai/agent_sdk/driver.rs | 6 ++-- .../driver/harness/exit_escalation.rs | 22 +++++++----- .../driver/harness/exit_escalation_tests.rs | 27 +++++++------- .../blocklist/local_agent_task_sync_model.rs | 2 +- .../local_agent_task_sync_model_tests.rs | 9 +++-- app/src/terminal/cli_agent_sessions/mod.rs | 27 ++++++++++---- .../terminal/cli_agent_sessions/mod_tests.rs | 35 +++++++++++++------ app/src/terminal/view.rs | 2 +- 9 files changed, 84 insertions(+), 48 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index 35fb2ec0cdb..9888cb10e7b 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -206,7 +206,7 @@ impl AgentNotificationsModel { ctx, ); } - CLIAgentSessionStatus::Blocked { message } => { + CLIAgentSessionStatus::Blocked { message, .. } => { let title = session_context .display_title() .unwrap_or_else(|| format!("{} needs attention", agent.display_name())); diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 929e44f608f..c0ad6d18e78 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -3217,10 +3217,8 @@ impl AgentDriver { .context("Failed to enqueue periodic harness conversation save")); } _ = harness_exit_rx => { - let start_event = if Self::session_blocked_on_needs_input(foreground).await { - ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput - } else { - ExitEscalationEvent::ShutdownRequested + let start_event = ExitEscalationEvent::ShutdownRequested { + awaiting_input: Self::session_blocked_on_needs_input(foreground).await, }; break Self::escalate_harness_exit( runner.as_ref(), diff --git a/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs b/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs index ed7b7dcf5b5..a9af0941649 100644 --- a/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs +++ b/app/src/ai/agent_sdk/driver/harness/exit_escalation.rs @@ -19,9 +19,10 @@ pub(crate) enum ExitEscalationPhase { pub(crate) enum ExitEscalationEvent { CommandExited, /// CLI session reached a terminal state and asked the driver to stop the harness. - ShutdownRequested, - /// Same request, but the CLI session is blocked on user input. - ShutdownRequestedWhileAwaitingInput, + ShutdownRequested { + /// The session is blocked on user input, so typed text would answer the open prompt. + awaiting_input: bool, + }, /// Runtime-failure scanner confirmed a hang/auth failure and asked the driver to stop. ScannerDetected, FollowupDeadlineElapsed, @@ -69,14 +70,19 @@ impl ExitEscalation { } ( ExitEscalationPhase::Running, - ExitEscalationEvent::ShutdownRequested | ExitEscalationEvent::ScannerDetected, + ExitEscalationEvent::ShutdownRequested { + awaiting_input: false, + } + | ExitEscalationEvent::ScannerDetected, ) => { self.phase = ExitEscalationPhase::AwaitingGracefulExit; ExitEscalationAction::SendExit } ( ExitEscalationPhase::Running, - ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput, + ExitEscalationEvent::ShutdownRequested { + awaiting_input: true, + }, ) => { self.phase = ExitEscalationPhase::Done; ExitEscalationAction::ForceKillAndFinish @@ -98,16 +104,14 @@ impl ExitEscalation { ( ExitEscalationPhase::Done, ExitEscalationEvent::CommandExited - | ExitEscalationEvent::ShutdownRequested - | ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput + | ExitEscalationEvent::ShutdownRequested { .. } | ExitEscalationEvent::ScannerDetected | ExitEscalationEvent::FollowupDeadlineElapsed | ExitEscalationEvent::ForceKillDeadlineElapsed, ) | ( ExitEscalationPhase::AwaitingGracefulExit | ExitEscalationPhase::AwaitingFollowup, - ExitEscalationEvent::ShutdownRequested - | ExitEscalationEvent::ShutdownRequestedWhileAwaitingInput + ExitEscalationEvent::ShutdownRequested { .. } | ExitEscalationEvent::ScannerDetected, ) | ( diff --git a/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs b/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs index ee99fd56e59..778591149e6 100644 --- a/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs +++ b/app/src/ai/agent_sdk/driver/harness/exit_escalation_tests.rs @@ -4,6 +4,13 @@ use super::{ }; use crate::ai::agent_sdk::driver::AgentDriverError; +const SHUTDOWN: ExitEscalationEvent = ExitEscalationEvent::ShutdownRequested { + awaiting_input: false, +}; +const SHUTDOWN_AWAITING_INPUT: ExitEscalationEvent = ExitEscalationEvent::ShutdownRequested { + awaiting_input: true, +}; + fn actions_for(events: &[ExitEscalationEvent]) -> (ExitEscalation, Vec) { let mut escalation = ExitEscalation::new(); let actions = events @@ -16,10 +23,7 @@ fn actions_for(events: &[ExitEscalationEvent]) -> (ExitEscalation, Vec ( + CLIAgentSessionStatus::Blocked { message, .. } => ( AgentTaskState::Blocked, message.as_ref().map(TaskStatusUpdate::message), ), diff --git a/app/src/ai/blocklist/local_agent_task_sync_model_tests.rs b/app/src/ai/blocklist/local_agent_task_sync_model_tests.rs index 27fd1be73a1..f74ae84afe8 100644 --- a/app/src/ai/blocklist/local_agent_task_sync_model_tests.rs +++ b/app/src/ai/blocklist/local_agent_task_sync_model_tests.rs @@ -25,7 +25,7 @@ use crate::ai::llms::LLMId; use crate::server::server_api::ai::{AIClient, MockAIClient, TaskStatusUpdate}; use crate::terminal::CLIAgent; use crate::terminal::cli_agent_sessions::{ - CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, + BlockedSource, CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; /// Helper to assert a (state, Option) tuple. @@ -467,6 +467,7 @@ fn cli_success_maps_correctly() { fn cli_blocked_maps_correctly() { let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked { message: Some("needs approval".into()), + source: BlockedSource::PermissionRequest, }); assert_eq!(state, AgentTaskState::Blocked); let update = update.expect("should have status update"); @@ -475,7 +476,10 @@ fn cli_blocked_maps_correctly() { #[test] fn cli_blocked_without_message() { - let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked { message: None }); + let (state, update) = map_cli_session_status(&CLIAgentSessionStatus::Blocked { + message: None, + source: BlockedSource::PermissionRequest, + }); assert_eq!(state, AgentTaskState::Blocked); assert!(update.is_none()); } @@ -709,6 +713,7 @@ fn confirmed_terminal_state_remembers_blocked() { terminal_view_id, CLIAgentSessionStatus::Blocked { message: Some("needs approval".into()), + source: BlockedSource::PermissionRequest, }, ); diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index edd9fc85a2c..9e6a95255f7 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -22,6 +22,14 @@ pub const CTRL_C_CANCEL_WINDOW: Duration = Duration::from_secs(2); const NEEDS_INPUT_STATUS_MESSAGE: &str = "Agent requires user input"; +/// What caused a session to become [`CLIAgentSessionStatus::Blocked`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockedSource { + PermissionRequest, + QuestionAsked, + NeedsInput, +} + /// Status of a tracked CLI agent session. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CLIAgentSessionStatus { @@ -33,6 +41,7 @@ pub enum CLIAgentSessionStatus { }, Blocked { message: Option, + source: BlockedSource, }, /// The user interrupted the session with Ctrl-C and no further plugin /// activity was observed within the grace window (see @@ -48,7 +57,7 @@ impl CLIAgentSessionStatus { CLIAgentSessionStatus::InProgress => ConversationStatus::InProgress, CLIAgentSessionStatus::Success => ConversationStatus::Success, CLIAgentSessionStatus::Failed { .. } => ConversationStatus::Error, - CLIAgentSessionStatus::Blocked { message } => ConversationStatus::Blocked { + CLIAgentSessionStatus::Blocked { message, .. } => ConversationStatus::Blocked { blocked_action: message.clone().unwrap_or_default(), }, CLIAgentSessionStatus::Cancelled => ConversationStatus::Cancelled, @@ -67,8 +76,6 @@ pub struct CLIAgentSessionContext { pub summary: Option, pub query: Option, pub response: Option, - /// Whether the latest status change was a `NeedsInput` event. - pub blocked_on_needs_input: bool, } /// State of the rich input editor for composing a prompt to send to a CLI agent. @@ -190,8 +197,13 @@ impl CLIAgentSession { /// typed into its terminal. #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn is_blocked_on_needs_input(&self) -> bool { - matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) - && self.session_context.blocked_on_needs_input + matches!( + self.status, + CLIAgentSessionStatus::Blocked { + source: BlockedSource::NeedsInput, + .. + } + ) } /// Clears state populated by `PermissionRequest`. Called whenever the @@ -254,6 +266,7 @@ impl CLIAgentSession { self.session_context.tool_input_preview = event.payload.tool_input_preview.clone(); CLIAgentSessionStatus::Blocked { message: event.payload.summary.clone(), + source: BlockedSource::PermissionRequest, } } CLIAgentEventType::QuestionAsked => CLIAgentSessionStatus::Blocked { @@ -262,6 +275,7 @@ impl CLIAgentSession { .summary .clone() .or_else(|| Some("Waiting for your answer".to_owned())), + source: BlockedSource::QuestionAsked, }, CLIAgentEventType::NeedsInput => CLIAgentSessionStatus::Blocked { message: event @@ -269,6 +283,7 @@ impl CLIAgentSession { .summary .clone() .or_else(|| Some(NEEDS_INPUT_STATUS_MESSAGE.to_owned())), + source: BlockedSource::NeedsInput, }, CLIAgentEventType::PermissionReplied => { if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) { @@ -287,8 +302,6 @@ impl CLIAgentSession { CLIAgentEventType::Unknown(_) => return None, }; - self.session_context.blocked_on_needs_input = - matches!(event.event, CLIAgentEventType::NeedsInput); self.status = new_status.clone(); Some(new_status) } diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 8994c5dbb7f..3c9aa96eecf 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -8,8 +8,8 @@ use super::event::{ CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, parse_event, }; use super::{ - CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, - CLIAgentSessionStatus, CLIAgentSessionsModel, + BlockedSource, CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, + CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; use crate::ai::blocklist::{InputConfig, InputType}; use crate::terminal::CLIAgent; @@ -516,6 +516,7 @@ fn blocked_claude_session_with_permission_state() -> CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::Blocked { message: Some("Wants to run bash: rm -rf /tmp".to_owned()), + source: BlockedSource::PermissionRequest, }, session_context: CLIAgentSessionContext { summary: Some("Wants to run bash: rm -rf /tmp".to_owned()), @@ -729,6 +730,7 @@ fn needs_input_blocks_session_with_the_event_summary() { let expected = CLIAgentSessionStatus::Blocked { message: Some("Some dialog text".to_owned()), + source: BlockedSource::NeedsInput, }; assert_eq!(new_status, Some(expected.clone())); assert_eq!(session.status, expected); @@ -743,26 +745,37 @@ fn needs_input_without_a_summary_blocks_session_with_the_generic_message() { let expected = CLIAgentSessionStatus::Blocked { message: Some("Agent requires user input".to_owned()), + source: BlockedSource::NeedsInput, }; assert_eq!(new_status, Some(expected)); assert!(session.is_blocked_on_needs_input()); } #[test] -fn other_blocked_events_are_not_blocked_on_needs_input() { - for event in [ - CLIAgentEventType::PermissionRequest, - CLIAgentEventType::QuestionAsked, +fn blocked_status_records_the_event_that_caused_it() { + for (event, expected_source) in [ + ( + CLIAgentEventType::PermissionRequest, + BlockedSource::PermissionRequest, + ), + ( + CLIAgentEventType::QuestionAsked, + BlockedSource::QuestionAsked, + ), + (CLIAgentEventType::NeedsInput, BlockedSource::NeedsInput), ] { let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); session.apply_event(&rich_event(event)); - assert!(matches!( - session.status, - CLIAgentSessionStatus::Blocked { .. } - )); - assert!(!session.is_blocked_on_needs_input()); + let CLIAgentSessionStatus::Blocked { source, .. } = session.status else { + panic!("expected a blocked status"); + }; + assert_eq!(source, expected_source); + assert_eq!( + session.is_blocked_on_needs_input(), + expected_source == BlockedSource::NeedsInput + ); } } diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index ae98418629e..52b32a6908e 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -14117,7 +14117,7 @@ impl TerminalView { .or(session_context.summary.as_deref().filter(|s| !s.is_empty())) .unwrap_or(agent.command_prefix()) .to_owned(); - let description = if let CLIAgentSessionStatus::Blocked { message } = status { + let description = if let CLIAgentSessionStatus::Blocked { message, .. } = status { message.clone().unwrap_or_default() } else { session_context.response.clone().unwrap_or_default()