From 0f46e4ea141ca7bb86a17c04b1f7c641de203470 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:01:43 +0000 Subject: [PATCH 1/4] Debounce third-party CLI session success reporting --- .../blocklist/local_agent_task_sync_model.rs | 56 ++++++- .../local_agent_task_sync_model_tests.rs | 155 +++++++++++++++--- 2 files changed, 186 insertions(+), 25 deletions(-) diff --git a/app/src/ai/blocklist/local_agent_task_sync_model.rs b/app/src/ai/blocklist/local_agent_task_sync_model.rs index fda5dc09ae0..7f38258db50 100644 --- a/app/src/ai/blocklist/local_agent_task_sync_model.rs +++ b/app/src/ai/blocklist/local_agent_task_sync_model.rs @@ -3,11 +3,13 @@ mod update_queue; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; +use std::time::Duration; use futures::channel::oneshot; use session_sharing_protocol::common::SessionId; use update_queue::LocalTaskUpdateQueue; use warp_graphql::ai::{AgentTaskState, PlatformErrorCode}; +use warpui::r#async::Timer; use warpui::{Entity, EntityId, ModelContext, SingletonEntity}; use super::history_model::{ @@ -22,6 +24,9 @@ use crate::terminal::cli_agent_sessions::{ CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; +/// A CLI turn boundary is not a task boundary; wait for the session to stay quiet. +const CLI_SESSION_SUCCESS_REPORT_DELAY: Duration = Duration::from_secs(120); + /// Syncs locally-owned conversation state to the server `ai_tasks` row via /// `AIClient::update_agent_task`. This includes task state, status message, /// server conversation token (`conversation_id`), and shared session ID. @@ -45,6 +50,9 @@ pub struct LocalAgentTaskSyncModel { /// live for the process-scoped `AgentDriver` run, which can span multiple /// pane-scoped CLI agent sessions. cli_session_task_ids: HashMap, + cli_success_report_delay: Duration, + cli_success_report_generation: u64, + pending_cli_success_reports: HashMap, /// Serializes and coalesces model-owned updates independently per task. update_queue: LocalTaskUpdateQueue, /// Senders resolved when the corresponding task's update queue drains @@ -105,6 +113,9 @@ impl LocalAgentTaskSyncModel { Self { ai_client, cli_session_task_ids: HashMap::new(), + cli_success_report_delay: CLI_SESSION_SUCCESS_REPORT_DELAY, + cli_success_report_generation: 0, + pending_cli_success_reports: HashMap::new(), update_queue: LocalTaskUpdateQueue::default(), idle_waiters: HashMap::new(), confirmed_terminal_states: HashMap::new(), @@ -155,9 +166,12 @@ impl LocalAgentTaskSyncModel { #[cfg(test)] pub(super) fn new_with_ai_client_for_test( ai_client: Arc, + cli_success_report_delay: Duration, ctx: &mut ModelContext, ) -> Self { - Self::new_with_ai_client(ai_client, ctx) + let mut model = Self::new_with_ai_client(ai_client, ctx); + model.cli_success_report_delay = cli_success_report_delay; + model } /// Registers a terminal view as a tracked CLI agent session so that @@ -221,6 +235,7 @@ impl LocalAgentTaskSyncModel { /// finishes. #[cfg_attr(target_family = "wasm", expect(dead_code))] pub fn unregister_cli_session(&mut self, terminal_view_id: EntityId) { + self.pending_cli_success_reports.remove(&terminal_view_id); if let Some(task_id) = self.cli_session_task_ids.remove(&terminal_view_id) { self.update_queue.remove_task(&task_id); } @@ -378,6 +393,45 @@ impl LocalAgentTaskSyncModel { let Some(&task_id) = self.cli_session_task_ids.get(&terminal_view_id) else { return; }; + match status { + CLIAgentSessionStatus::Success => { + self.cli_success_report_generation += 1; + let generation = self.cli_success_report_generation; + self.pending_cli_success_reports + .insert(terminal_view_id, generation); + let delay = self.cli_success_report_delay; + ctx.spawn( + async move { Timer::after(delay).await }, + move |me, _, ctx| { + if me.pending_cli_success_reports.get(&terminal_view_id) + != Some(&generation) + || !me.cli_session_task_ids.contains_key(&terminal_view_id) + { + return; + } + me.pending_cli_success_reports.remove(&terminal_view_id); + let (task_state, status_message) = + map_cli_session_status(&CLIAgentSessionStatus::Success); + me.enqueue_update( + task_id, + LocalTaskUpdate { + task_state: Some(task_state), + status_message, + ..LocalTaskUpdate::default() + }, + ctx, + ); + }, + ); + return; + } + CLIAgentSessionStatus::InProgress + | CLIAgentSessionStatus::Failed { .. } + | CLIAgentSessionStatus::Blocked { .. } + | CLIAgentSessionStatus::Cancelled => { + self.pending_cli_success_reports.remove(&terminal_view_id); + } + } let (task_state, status_message) = map_cli_session_status(status); self.enqueue_update( 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 111e801e6cc..bfa068e8869 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 @@ -537,7 +537,11 @@ fn install_model_with_call_counter( }); let ai_client: Arc = Arc::new(mock); let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + LocalAgentTaskSyncModel::new_with_ai_client_for_test( + ai_client, + Duration::from_millis(5), + ctx, + ) }); (model, counter) } @@ -549,27 +553,41 @@ fn register_cli_agent_sessions_model(app: &mut App) { app.add_singleton_model(|_| CLIAgentSessionsModel::new()); } +fn install_cli_model_with_success_counter( + app: &mut App, + delay: Duration, +) -> ( + warpui::ModelHandle, + warpui::ModelHandle, + Arc, +) { + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let cli_sessions_model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + let succeeded_updates = Arc::new(AtomicUsize::new(0)); + let succeeded_updates_for_mock = succeeded_updates.clone(); + let mut mock = MockAIClient::new(); + mock.expect_update_agent_task() + .returning(move |_, task_state, _, _, status_message, _, _| { + if task_state == Some(AgentTaskState::Succeeded) { + assert!(status_message.is_none()); + succeeded_updates_for_mock.fetch_add(1, Ordering::SeqCst); + } + Ok(()) + }); + let ai_client: Arc = Arc::new(mock); + let model = app.add_singleton_model(|ctx| { + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, delay, ctx) + }); + (model, cli_sessions_model, succeeded_updates) +} + /// A pane-scoped CLI session can end during setup without ending the driver run, /// while explicit driver cleanup prevents later status updates. #[test] fn cli_task_mapping_survives_cli_session_end() { App::test((), |mut app| async move { - app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); - let cli_sessions_model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); - let succeeded_updates = Arc::new(AtomicUsize::new(0)); - let succeeded_updates_for_mock = succeeded_updates.clone(); - let mut mock = MockAIClient::new(); - mock.expect_update_agent_task() - .returning(move |_, task_state, _, _, _, _, _| { - if task_state == Some(AgentTaskState::Succeeded) { - succeeded_updates_for_mock.fetch_add(1, Ordering::SeqCst); - } - Ok(()) - }); - let ai_client: Arc = Arc::new(mock); - let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) - }); + let (model, cli_sessions_model, succeeded_updates) = + install_cli_model_with_success_counter(&mut app, Duration::from_millis(5)); let terminal_view_id = warpui::EntityId::new(); model.update(&mut app, |model, ctx| { @@ -590,6 +608,8 @@ fn cli_task_mapping_survives_cli_session_end() { session_context: Box::default(), }); }); + pump_spawned_tasks().await; + assert_eq!(succeeded_updates.load(Ordering::SeqCst), 1); model.update(&mut app, |model, _| { model.unregister_cli_session(terminal_view_id); }); @@ -613,6 +633,78 @@ fn cli_task_mapping_survives_cli_session_end() { }); } +#[test] +fn cli_success_waits_for_the_final_quiet_period() { + App::test((), |mut app| async move { + let (model, cli_sessions_model, succeeded_updates) = + install_cli_model_with_success_counter(&mut app, Duration::from_millis(50)); + let terminal_view_id = EntityId::new(); + model.update(&mut app, |model, ctx| { + model.register_cli_session(terminal_view_id, fixed_task_id(), ctx); + }); + emit_cli_status( + &cli_sessions_model, + &mut app, + terminal_view_id, + CLIAgentSessionStatus::Success, + ); + pump_spawned_tasks().await; + warpui::r#async::Timer::after(Duration::from_millis(25)).await; + assert_eq!(succeeded_updates.load(Ordering::SeqCst), 0); + + cli_sessions_model.update(&mut app, |_, ctx| { + ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { + terminal_view_id, + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + is_prompt_submit: true, + session_context: Box::default(), + }); + }); + emit_cli_status( + &cli_sessions_model, + &mut app, + terminal_view_id, + CLIAgentSessionStatus::Success, + ); + pump_spawned_tasks().await; + warpui::r#async::Timer::after(Duration::from_millis(10)).await; + assert_eq!( + succeeded_updates.load(Ordering::SeqCst), + 0, + "the cancelled timer must not report the replacement success at its old deadline" + ); + + warpui::r#async::Timer::after(Duration::from_millis(40)).await; + pump_spawned_tasks().await; + assert_eq!(succeeded_updates.load(Ordering::SeqCst), 1); + }); +} + +#[test] +fn unregister_cli_session_cancels_pending_success() { + App::test((), |mut app| async move { + let (model, cli_sessions_model, succeeded_updates) = + install_cli_model_with_success_counter(&mut app, Duration::from_millis(5)); + let terminal_view_id = EntityId::new(); + model.update(&mut app, |model, ctx| { + model.register_cli_session(terminal_view_id, fixed_task_id(), ctx); + }); + emit_cli_status( + &cli_sessions_model, + &mut app, + terminal_view_id, + CLIAgentSessionStatus::Success, + ); + model.update(&mut app, |model, _| { + model.unregister_cli_session(terminal_view_id); + }); + + pump_spawned_tasks().await; + assert_eq!(succeeded_updates.load(Ordering::SeqCst), 0); + }); +} + /// Installs the model with a mock whose `update_agent_task` returns the /// given result for every call. fn install_model_with_constant_result( @@ -635,7 +727,11 @@ fn install_model_with_constant_result( }); let ai_client: Arc = Arc::new(mock); let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + LocalAgentTaskSyncModel::new_with_ai_client_for_test( + ai_client, + Duration::from_millis(5), + ctx, + ) }); (model, cli_sessions_model) } @@ -737,9 +833,6 @@ fn wait_for_idle_resolves_after_updates_deliver_and_terminal_state_is_confirmed( let terminal_view_id = warpui::EntityId::new(); let task_id = fixed_task_id(); - // `register_cli_session` puts an IN_PROGRESS update in flight, and the - // status change queues a terminal SUCCEEDED update behind it, so the - // waiter below is registered against a non-idle queue. model.update(&mut app, |model, ctx| { model.register_cli_session(terminal_view_id, task_id, ctx); }); @@ -754,6 +847,7 @@ fn wait_for_idle_resolves_after_updates_deliver_and_terminal_state_is_confirmed( wait.with_timeout(Duration::from_secs(5)) .await .expect("wait_for_idle must resolve once queued updates finish delivering"); + pump_spawned_tasks().await; let confirmed = model.update(&mut app, |model, _| { model.confirmed_terminal_state(&task_id) @@ -820,6 +914,7 @@ fn failed_delivery_does_not_confirm_terminal_state() { wait.with_timeout(Duration::from_secs(5)) .await .expect("wait_for_idle must resolve even when deliveries fail"); + pump_spawned_tasks().await; let confirmed = model.update(&mut app, |model, _| { model.confirmed_terminal_state(&task_id) @@ -899,7 +994,11 @@ fn shared_session_link_uses_correct_argument_order() { .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + LocalAgentTaskSyncModel::new_with_ai_client_for_test( + ai_client, + Duration::from_millis(5), + ctx, + ) }); history_model.update(&mut app, |_, ctx| { @@ -1079,7 +1178,11 @@ fn conversation_server_token_assigned_fires_update_with_conversation_id() { .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + LocalAgentTaskSyncModel::new_with_ai_client_for_test( + ai_client, + Duration::from_millis(5), + ctx, + ) }); history_model.update(&mut app, |_, ctx| { @@ -1201,7 +1304,11 @@ fn preserve_terminal_setup_failure_conversation_reports_token_without_task_state .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + LocalAgentTaskSyncModel::new_with_ai_client_for_test( + ai_client, + Duration::from_millis(5), + ctx, + ) }); // A debug turn running to a terminal Error status must not construct a task-state From afe6c836043cc1cd12c35c70512c9e3f8b36532c Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:37 +0000 Subject: [PATCH 2/4] Group CLI success reporting debounce state --- .../blocklist/local_agent_task_sync_model.rs | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/app/src/ai/blocklist/local_agent_task_sync_model.rs b/app/src/ai/blocklist/local_agent_task_sync_model.rs index 7f38258db50..bd91a01424f 100644 --- a/app/src/ai/blocklist/local_agent_task_sync_model.rs +++ b/app/src/ai/blocklist/local_agent_task_sync_model.rs @@ -50,9 +50,7 @@ pub struct LocalAgentTaskSyncModel { /// live for the process-scoped `AgentDriver` run, which can span multiple /// pane-scoped CLI agent sessions. cli_session_task_ids: HashMap, - cli_success_report_delay: Duration, - cli_success_report_generation: u64, - pending_cli_success_reports: HashMap, + cli_success_report_debounce: CLISuccessReportDebounce, /// Serializes and coalesces model-owned updates independently per task. update_queue: LocalTaskUpdateQueue, /// Senders resolved when the corresponding task's update queue drains @@ -64,6 +62,12 @@ pub struct LocalAgentTaskSyncModel { confirmed_terminal_states: HashMap, } +struct CLISuccessReportDebounce { + delay: Duration, + generation: u64, + pending_reports: HashMap, +} + pub enum LocalAgentTaskSyncModelEvent {} /// Aggregated update to send via `AIClient::update_agent_task`. Field names @@ -113,9 +117,11 @@ impl LocalAgentTaskSyncModel { Self { ai_client, cli_session_task_ids: HashMap::new(), - cli_success_report_delay: CLI_SESSION_SUCCESS_REPORT_DELAY, - cli_success_report_generation: 0, - pending_cli_success_reports: HashMap::new(), + cli_success_report_debounce: CLISuccessReportDebounce { + delay: CLI_SESSION_SUCCESS_REPORT_DELAY, + generation: 0, + pending_reports: HashMap::new(), + }, update_queue: LocalTaskUpdateQueue::default(), idle_waiters: HashMap::new(), confirmed_terminal_states: HashMap::new(), @@ -170,7 +176,7 @@ impl LocalAgentTaskSyncModel { ctx: &mut ModelContext, ) -> Self { let mut model = Self::new_with_ai_client(ai_client, ctx); - model.cli_success_report_delay = cli_success_report_delay; + model.cli_success_report_debounce.delay = cli_success_report_delay; model } @@ -235,7 +241,9 @@ impl LocalAgentTaskSyncModel { /// finishes. #[cfg_attr(target_family = "wasm", expect(dead_code))] pub fn unregister_cli_session(&mut self, terminal_view_id: EntityId) { - self.pending_cli_success_reports.remove(&terminal_view_id); + self.cli_success_report_debounce + .pending_reports + .remove(&terminal_view_id); if let Some(task_id) = self.cli_session_task_ids.remove(&terminal_view_id) { self.update_queue.remove_task(&task_id); } @@ -395,21 +403,23 @@ impl LocalAgentTaskSyncModel { }; match status { CLIAgentSessionStatus::Success => { - self.cli_success_report_generation += 1; - let generation = self.cli_success_report_generation; - self.pending_cli_success_reports + let debounce = &mut self.cli_success_report_debounce; + debounce.generation += 1; + let generation = debounce.generation; + debounce + .pending_reports .insert(terminal_view_id, generation); - let delay = self.cli_success_report_delay; + let delay = debounce.delay; ctx.spawn( async move { Timer::after(delay).await }, move |me, _, ctx| { - if me.pending_cli_success_reports.get(&terminal_view_id) - != Some(&generation) + let debounce = &mut me.cli_success_report_debounce; + if debounce.pending_reports.get(&terminal_view_id) != Some(&generation) || !me.cli_session_task_ids.contains_key(&terminal_view_id) { return; } - me.pending_cli_success_reports.remove(&terminal_view_id); + debounce.pending_reports.remove(&terminal_view_id); let (task_state, status_message) = map_cli_session_status(&CLIAgentSessionStatus::Success); me.enqueue_update( @@ -429,7 +439,9 @@ impl LocalAgentTaskSyncModel { | CLIAgentSessionStatus::Failed { .. } | CLIAgentSessionStatus::Blocked { .. } | CLIAgentSessionStatus::Cancelled => { - self.pending_cli_success_reports.remove(&terminal_view_id); + self.cli_success_report_debounce + .pending_reports + .remove(&terminal_view_id); } } From d1db182e645821a202d5035a842ac3563480eb01 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 8 Oct 2026 17:38:46 -0400 Subject: [PATCH 3/4] Keep CLI harness sessions in progress while background work is pending Claude Code fires Stop at the end of every assistant turn, including turns that end while background tasks or session crons will wake the agent again. Treating each of those as task success made third-party harness runs flap between SUCCEEDED and IN_PROGRESS on every wake, notifying integrations and arming the idle-exit timer each time. The warp plugin (2.4.0) now reports pending background work on the stop event. A Stop that carries any is a pause rather than a completion: the session keeps its status, so no task state is reported and the idle timer is not armed, until a Stop with nothing pending arrives. finish-task and the exit-time fallback are unchanged. Replaces the debounce approach and requires warp plugin 2.4.0. Co-Authored-By: Warp --- .../blocklist/local_agent_task_sync_model.rs | 68 +------- .../local_agent_task_sync_model_tests.rs | 155 +++--------------- .../terminal/cli_agent_sessions/event/mod.rs | 14 ++ .../terminal/cli_agent_sessions/event/v1.rs | 2 + app/src/terminal/cli_agent_sessions/mod.rs | 5 + .../terminal/cli_agent_sessions/mod_tests.rs | 28 ++++ .../plugin_manager/claude.rs | 2 +- crates/warp_core/src/cli_agent_protocol.rs | 6 + 8 files changed, 81 insertions(+), 199 deletions(-) diff --git a/app/src/ai/blocklist/local_agent_task_sync_model.rs b/app/src/ai/blocklist/local_agent_task_sync_model.rs index bd91a01424f..fda5dc09ae0 100644 --- a/app/src/ai/blocklist/local_agent_task_sync_model.rs +++ b/app/src/ai/blocklist/local_agent_task_sync_model.rs @@ -3,13 +3,11 @@ mod update_queue; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; -use std::time::Duration; use futures::channel::oneshot; use session_sharing_protocol::common::SessionId; use update_queue::LocalTaskUpdateQueue; use warp_graphql::ai::{AgentTaskState, PlatformErrorCode}; -use warpui::r#async::Timer; use warpui::{Entity, EntityId, ModelContext, SingletonEntity}; use super::history_model::{ @@ -24,9 +22,6 @@ use crate::terminal::cli_agent_sessions::{ CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; -/// A CLI turn boundary is not a task boundary; wait for the session to stay quiet. -const CLI_SESSION_SUCCESS_REPORT_DELAY: Duration = Duration::from_secs(120); - /// Syncs locally-owned conversation state to the server `ai_tasks` row via /// `AIClient::update_agent_task`. This includes task state, status message, /// server conversation token (`conversation_id`), and shared session ID. @@ -50,7 +45,6 @@ pub struct LocalAgentTaskSyncModel { /// live for the process-scoped `AgentDriver` run, which can span multiple /// pane-scoped CLI agent sessions. cli_session_task_ids: HashMap, - cli_success_report_debounce: CLISuccessReportDebounce, /// Serializes and coalesces model-owned updates independently per task. update_queue: LocalTaskUpdateQueue, /// Senders resolved when the corresponding task's update queue drains @@ -62,12 +56,6 @@ pub struct LocalAgentTaskSyncModel { confirmed_terminal_states: HashMap, } -struct CLISuccessReportDebounce { - delay: Duration, - generation: u64, - pending_reports: HashMap, -} - pub enum LocalAgentTaskSyncModelEvent {} /// Aggregated update to send via `AIClient::update_agent_task`. Field names @@ -117,11 +105,6 @@ impl LocalAgentTaskSyncModel { Self { ai_client, cli_session_task_ids: HashMap::new(), - cli_success_report_debounce: CLISuccessReportDebounce { - delay: CLI_SESSION_SUCCESS_REPORT_DELAY, - generation: 0, - pending_reports: HashMap::new(), - }, update_queue: LocalTaskUpdateQueue::default(), idle_waiters: HashMap::new(), confirmed_terminal_states: HashMap::new(), @@ -172,12 +155,9 @@ impl LocalAgentTaskSyncModel { #[cfg(test)] pub(super) fn new_with_ai_client_for_test( ai_client: Arc, - cli_success_report_delay: Duration, ctx: &mut ModelContext, ) -> Self { - let mut model = Self::new_with_ai_client(ai_client, ctx); - model.cli_success_report_debounce.delay = cli_success_report_delay; - model + Self::new_with_ai_client(ai_client, ctx) } /// Registers a terminal view as a tracked CLI agent session so that @@ -241,9 +221,6 @@ impl LocalAgentTaskSyncModel { /// finishes. #[cfg_attr(target_family = "wasm", expect(dead_code))] pub fn unregister_cli_session(&mut self, terminal_view_id: EntityId) { - self.cli_success_report_debounce - .pending_reports - .remove(&terminal_view_id); if let Some(task_id) = self.cli_session_task_ids.remove(&terminal_view_id) { self.update_queue.remove_task(&task_id); } @@ -401,49 +378,6 @@ impl LocalAgentTaskSyncModel { let Some(&task_id) = self.cli_session_task_ids.get(&terminal_view_id) else { return; }; - match status { - CLIAgentSessionStatus::Success => { - let debounce = &mut self.cli_success_report_debounce; - debounce.generation += 1; - let generation = debounce.generation; - debounce - .pending_reports - .insert(terminal_view_id, generation); - let delay = debounce.delay; - ctx.spawn( - async move { Timer::after(delay).await }, - move |me, _, ctx| { - let debounce = &mut me.cli_success_report_debounce; - if debounce.pending_reports.get(&terminal_view_id) != Some(&generation) - || !me.cli_session_task_ids.contains_key(&terminal_view_id) - { - return; - } - debounce.pending_reports.remove(&terminal_view_id); - let (task_state, status_message) = - map_cli_session_status(&CLIAgentSessionStatus::Success); - me.enqueue_update( - task_id, - LocalTaskUpdate { - task_state: Some(task_state), - status_message, - ..LocalTaskUpdate::default() - }, - ctx, - ); - }, - ); - return; - } - CLIAgentSessionStatus::InProgress - | CLIAgentSessionStatus::Failed { .. } - | CLIAgentSessionStatus::Blocked { .. } - | CLIAgentSessionStatus::Cancelled => { - self.cli_success_report_debounce - .pending_reports - .remove(&terminal_view_id); - } - } let (task_state, status_message) = map_cli_session_status(status); self.enqueue_update( 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 bfa068e8869..111e801e6cc 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 @@ -537,11 +537,7 @@ fn install_model_with_call_counter( }); let ai_client: Arc = Arc::new(mock); let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test( - ai_client, - Duration::from_millis(5), - ctx, - ) + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) }); (model, counter) } @@ -553,41 +549,27 @@ fn register_cli_agent_sessions_model(app: &mut App) { app.add_singleton_model(|_| CLIAgentSessionsModel::new()); } -fn install_cli_model_with_success_counter( - app: &mut App, - delay: Duration, -) -> ( - warpui::ModelHandle, - warpui::ModelHandle, - Arc, -) { - app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); - let cli_sessions_model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); - let succeeded_updates = Arc::new(AtomicUsize::new(0)); - let succeeded_updates_for_mock = succeeded_updates.clone(); - let mut mock = MockAIClient::new(); - mock.expect_update_agent_task() - .returning(move |_, task_state, _, _, status_message, _, _| { - if task_state == Some(AgentTaskState::Succeeded) { - assert!(status_message.is_none()); - succeeded_updates_for_mock.fetch_add(1, Ordering::SeqCst); - } - Ok(()) - }); - let ai_client: Arc = Arc::new(mock); - let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, delay, ctx) - }); - (model, cli_sessions_model, succeeded_updates) -} - /// A pane-scoped CLI session can end during setup without ending the driver run, /// while explicit driver cleanup prevents later status updates. #[test] fn cli_task_mapping_survives_cli_session_end() { App::test((), |mut app| async move { - let (model, cli_sessions_model, succeeded_updates) = - install_cli_model_with_success_counter(&mut app, Duration::from_millis(5)); + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let cli_sessions_model = app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + let succeeded_updates = Arc::new(AtomicUsize::new(0)); + let succeeded_updates_for_mock = succeeded_updates.clone(); + let mut mock = MockAIClient::new(); + mock.expect_update_agent_task() + .returning(move |_, task_state, _, _, _, _, _| { + if task_state == Some(AgentTaskState::Succeeded) { + succeeded_updates_for_mock.fetch_add(1, Ordering::SeqCst); + } + Ok(()) + }); + let ai_client: Arc = Arc::new(mock); + let model = app.add_singleton_model(|ctx| { + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) + }); let terminal_view_id = warpui::EntityId::new(); model.update(&mut app, |model, ctx| { @@ -608,8 +590,6 @@ fn cli_task_mapping_survives_cli_session_end() { session_context: Box::default(), }); }); - pump_spawned_tasks().await; - assert_eq!(succeeded_updates.load(Ordering::SeqCst), 1); model.update(&mut app, |model, _| { model.unregister_cli_session(terminal_view_id); }); @@ -633,78 +613,6 @@ fn cli_task_mapping_survives_cli_session_end() { }); } -#[test] -fn cli_success_waits_for_the_final_quiet_period() { - App::test((), |mut app| async move { - let (model, cli_sessions_model, succeeded_updates) = - install_cli_model_with_success_counter(&mut app, Duration::from_millis(50)); - let terminal_view_id = EntityId::new(); - model.update(&mut app, |model, ctx| { - model.register_cli_session(terminal_view_id, fixed_task_id(), ctx); - }); - emit_cli_status( - &cli_sessions_model, - &mut app, - terminal_view_id, - CLIAgentSessionStatus::Success, - ); - pump_spawned_tasks().await; - warpui::r#async::Timer::after(Duration::from_millis(25)).await; - assert_eq!(succeeded_updates.load(Ordering::SeqCst), 0); - - cli_sessions_model.update(&mut app, |_, ctx| { - ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { - terminal_view_id, - agent: CLIAgent::Claude, - status: CLIAgentSessionStatus::InProgress, - is_prompt_submit: true, - session_context: Box::default(), - }); - }); - emit_cli_status( - &cli_sessions_model, - &mut app, - terminal_view_id, - CLIAgentSessionStatus::Success, - ); - pump_spawned_tasks().await; - warpui::r#async::Timer::after(Duration::from_millis(10)).await; - assert_eq!( - succeeded_updates.load(Ordering::SeqCst), - 0, - "the cancelled timer must not report the replacement success at its old deadline" - ); - - warpui::r#async::Timer::after(Duration::from_millis(40)).await; - pump_spawned_tasks().await; - assert_eq!(succeeded_updates.load(Ordering::SeqCst), 1); - }); -} - -#[test] -fn unregister_cli_session_cancels_pending_success() { - App::test((), |mut app| async move { - let (model, cli_sessions_model, succeeded_updates) = - install_cli_model_with_success_counter(&mut app, Duration::from_millis(5)); - let terminal_view_id = EntityId::new(); - model.update(&mut app, |model, ctx| { - model.register_cli_session(terminal_view_id, fixed_task_id(), ctx); - }); - emit_cli_status( - &cli_sessions_model, - &mut app, - terminal_view_id, - CLIAgentSessionStatus::Success, - ); - model.update(&mut app, |model, _| { - model.unregister_cli_session(terminal_view_id); - }); - - pump_spawned_tasks().await; - assert_eq!(succeeded_updates.load(Ordering::SeqCst), 0); - }); -} - /// Installs the model with a mock whose `update_agent_task` returns the /// given result for every call. fn install_model_with_constant_result( @@ -727,11 +635,7 @@ fn install_model_with_constant_result( }); let ai_client: Arc = Arc::new(mock); let model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test( - ai_client, - Duration::from_millis(5), - ctx, - ) + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) }); (model, cli_sessions_model) } @@ -833,6 +737,9 @@ fn wait_for_idle_resolves_after_updates_deliver_and_terminal_state_is_confirmed( let terminal_view_id = warpui::EntityId::new(); let task_id = fixed_task_id(); + // `register_cli_session` puts an IN_PROGRESS update in flight, and the + // status change queues a terminal SUCCEEDED update behind it, so the + // waiter below is registered against a non-idle queue. model.update(&mut app, |model, ctx| { model.register_cli_session(terminal_view_id, task_id, ctx); }); @@ -847,7 +754,6 @@ fn wait_for_idle_resolves_after_updates_deliver_and_terminal_state_is_confirmed( wait.with_timeout(Duration::from_secs(5)) .await .expect("wait_for_idle must resolve once queued updates finish delivering"); - pump_spawned_tasks().await; let confirmed = model.update(&mut app, |model, _| { model.confirmed_terminal_state(&task_id) @@ -914,7 +820,6 @@ fn failed_delivery_does_not_confirm_terminal_state() { wait.with_timeout(Duration::from_secs(5)) .await .expect("wait_for_idle must resolve even when deliveries fail"); - pump_spawned_tasks().await; let confirmed = model.update(&mut app, |model, _| { model.confirmed_terminal_state(&task_id) @@ -994,11 +899,7 @@ fn shared_session_link_uses_correct_argument_order() { .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test( - ai_client, - Duration::from_millis(5), - ctx, - ) + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) }); history_model.update(&mut app, |_, ctx| { @@ -1178,11 +1079,7 @@ fn conversation_server_token_assigned_fires_update_with_conversation_id() { .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test( - ai_client, - Duration::from_millis(5), - ctx, - ) + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) }); history_model.update(&mut app, |_, ctx| { @@ -1304,11 +1201,7 @@ fn preserve_terminal_setup_failure_conversation_reports_token_without_task_state .returning(|_, _, _, _, _, _, _| Ok(())); let ai_client: Arc = Arc::new(mock); let _model = app.add_singleton_model(|ctx| { - LocalAgentTaskSyncModel::new_with_ai_client_for_test( - ai_client, - Duration::from_millis(5), - ctx, - ) + LocalAgentTaskSyncModel::new_with_ai_client_for_test(ai_client, ctx) }); // A debug turn running to a terminal Error status must not construct a task-state diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index e1fbf4d1635..774e94b99cb 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -48,6 +48,20 @@ pub struct CLIAgentEventPayload { /// On Claude Code, this comes from the `StopFailure` hook (e.g. `"rate_limit"`). /// Not implemented for Codex. pub error_type: Option, + /// Background tasks still running when a `Stop` fired. Claude Code wakes the agent again + /// when one finishes, so a stop with pending tasks is a pause, not the end of the session. + /// `None` when the plugin predates this field. + pub background_task_count: Option, + /// Scheduled wakeups pending when a `Stop` fired; same semantics as + /// `background_task_count`. + pub session_cron_count: Option, +} + +impl CLIAgentEventPayload { + /// Whether the agent reported work that will wake it again without user input. + pub fn has_pending_background_work(&self) -> bool { + self.background_task_count.unwrap_or(0) > 0 || self.session_cron_count.unwrap_or(0) > 0 + } } /// A parsed event from a CLI agent plugin. diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index e16499431a4..9c75f33c4e7 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -55,6 +55,8 @@ pub(super) fn parse(body: &str) -> Option { tool_input_preview, plugin_version: raw.plugin_version, error_type: raw.error_type, + background_task_count: raw.background_task_count, + session_cron_count: raw.session_cron_count, }, source: CLIAgentEventSource::RichPlugin, }) diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 64cce01591b..f7433552868 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -249,6 +249,11 @@ impl CLIAgentSession { self.session_context.query = event.payload.query.clone(); self.session_context.response = event.payload.response.clone(); self.clear_permission_scoped_state(); + // The agent will wake itself again once that work finishes, so this turn + // boundary is a pause rather than the end of the session. + if event.payload.has_pending_background_work() { + return None; + } CLIAgentSessionStatus::Success } CLIAgentEventType::StopFailure => { diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 3c9aa96eecf..a764321b855 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -850,6 +850,34 @@ fn needs_input_is_ignored_in_the_desktop_app() { assert_needs_input_blocks_session_in_mode(ExecutionMode::App, false); } +#[test] +fn stop_with_pending_background_work_keeps_session_in_progress() { + let body = r#"{"v":1,"agent":"claude","event":"stop","query":"build it","response":"Build started in the background.","background_task_count":1,"session_cron_count":0}"#; + let event = parse_event(Some("warp://cli-agent"), body).unwrap(); + assert!(event.payload.has_pending_background_work()); + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + assert_eq!(session.apply_event(&event), None); + + assert_eq!(session.status, CLIAgentSessionStatus::InProgress); + assert_eq!( + session.session_context.response.as_deref(), + Some("Build started in the background.") + ); +} + +#[test] +fn stop_without_pending_background_work_succeeds() { + let body = r#"{"v":1,"agent":"claude","event":"stop","background_task_count":0,"session_cron_count":0}"#; + let event = parse_event(Some("warp://cli-agent"), body).unwrap(); + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + assert_eq!( + session.apply_event(&event), + Some(CLIAgentSessionStatus::Success) + ); +} + // --- Ctrl-C pending-cancel state machine --- /// Grace window used by the tests below. Long enough to comfortably diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs index 3d0843a18af..8cd2ebcc395 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs @@ -21,7 +21,7 @@ const MARKETPLACE_NAME: &str = "claude-code-warp"; // Keep in sync with the plugin version in warpdotdev/claude-code-warp. // (See the Versioning section of that repo's README.) -const MINIMUM_PLUGIN_VERSION: &str = "2.1.0"; +const MINIMUM_PLUGIN_VERSION: &str = "2.4.0"; // Keep in sync with the oz-harness-support plugin version in warpdotdev/claude-code-warp. const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "1.1.2"; diff --git a/crates/warp_core/src/cli_agent_protocol.rs b/crates/warp_core/src/cli_agent_protocol.rs index bc7b6cd42f5..a46516053cf 100644 --- a/crates/warp_core/src/cli_agent_protocol.rs +++ b/crates/warp_core/src/cli_agent_protocol.rs @@ -31,6 +31,10 @@ pub struct CLIAgentNotification { pub tool_input: Option, pub plugin_version: Option, pub error_type: Option, + /// Background tasks (shell, subagent, monitor, ...) still running when the agent stopped. + pub background_task_count: Option, + /// Session-scoped scheduled wakeups (cron, loop) pending when the agent stopped. + pub session_cron_count: Option, } impl CLIAgentNotification { @@ -50,6 +54,8 @@ impl CLIAgentNotification { tool_input: None, plugin_version: None, error_type: None, + background_task_count: None, + session_cron_count: None, } } } From 9deadbaa50b4fa7f1ea32d112412a1862f99b62e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 8 Oct 2026 17:49:23 -0400 Subject: [PATCH 4/4] Fold background task and session cron counts into one pending-work field Co-Authored-By: Warp --- app/src/terminal/cli_agent_sessions/event/mod.rs | 14 ++++++-------- app/src/terminal/cli_agent_sessions/event/v1.rs | 8 ++++++-- app/src/terminal/cli_agent_sessions/mod_tests.rs | 3 ++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index 774e94b99cb..a21ae2ddc0b 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -48,19 +48,17 @@ pub struct CLIAgentEventPayload { /// On Claude Code, this comes from the `StopFailure` hook (e.g. `"rate_limit"`). /// Not implemented for Codex. pub error_type: Option, - /// Background tasks still running when a `Stop` fired. Claude Code wakes the agent again - /// when one finishes, so a stop with pending tasks is a pause, not the end of the session. - /// `None` when the plugin predates this field. - pub background_task_count: Option, - /// Scheduled wakeups pending when a `Stop` fired; same semantics as - /// `background_task_count`. - pub session_cron_count: Option, + /// Background tasks and scheduled wakeups still pending when a `Stop` fired. Claude Code + /// wakes the agent again when any of them completes or fires, so a stop with pending work is + /// a pause, not the end of the session. `None` when the plugin predates this field. + pub pending_background_work_count: Option, } impl CLIAgentEventPayload { /// Whether the agent reported work that will wake it again without user input. pub fn has_pending_background_work(&self) -> bool { - self.background_task_count.unwrap_or(0) > 0 || self.session_cron_count.unwrap_or(0) > 0 + self.pending_background_work_count + .is_some_and(|count| count > 0) } } diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index 9c75f33c4e7..dcd1a33bce8 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -39,6 +39,11 @@ pub(super) fn parse(body: &str) -> Option { .and_then(resolve_agent) .unwrap_or(CLIAgent::Unknown); + let pending_background_work_count = match (raw.background_task_count, raw.session_cron_count) { + (None, None) => None, + (tasks, crons) => Some(tasks.unwrap_or(0).saturating_add(crons.unwrap_or(0))), + }; + Some(CLIAgentEvent { v: raw.v.unwrap_or(1), agent, @@ -55,8 +60,7 @@ pub(super) fn parse(body: &str) -> Option { tool_input_preview, plugin_version: raw.plugin_version, error_type: raw.error_type, - background_task_count: raw.background_task_count, - session_cron_count: raw.session_cron_count, + pending_background_work_count, }, source: CLIAgentEventSource::RichPlugin, }) diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index a764321b855..e3c00cb49fb 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -852,8 +852,9 @@ fn needs_input_is_ignored_in_the_desktop_app() { #[test] fn stop_with_pending_background_work_keeps_session_in_progress() { - let body = r#"{"v":1,"agent":"claude","event":"stop","query":"build it","response":"Build started in the background.","background_task_count":1,"session_cron_count":0}"#; + let body = r#"{"v":1,"agent":"claude","event":"stop","query":"build it","response":"Build started in the background.","background_task_count":1,"session_cron_count":2}"#; let event = parse_event(Some("warp://cli-agent"), body).unwrap(); + assert_eq!(event.payload.pending_background_work_count, Some(3)); assert!(event.payload.has_pending_background_work()); let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true);