From 2790407ad50bae5f385e14ca5ae6f51f267697b7 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Fri, 18 Sep 2026 16:30:34 -0500 Subject: [PATCH] Fix child agent startup routing and terminal failure status --- app/src/ai/agent/conversation.rs | 6 +- app/src/ai/blocklist/controller.rs | 55 +++++++++++++++-- app/src/ai/blocklist/controller_tests.rs | 78 ++++++++++++++++++++++++ app/src/terminal/view.rs | 2 +- 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 2a719987..f2c053a5 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -3445,7 +3445,7 @@ impl AIConversation { }; let current_comment_state = self.code_review.as_ref().cloned(); - task.add_messages( + let add_result = task.add_messages( messages, exchange_id, TaskMessageContext { @@ -3459,9 +3459,11 @@ impl AIConversation { // to mimic the normal conversation flow. (If this is not a shared session, the // exchange inputs will already be populated). self.is_viewing_shared_session, - )?; + ); + // Keep the task available for failure reporting even if message projection fails. self.task_store.insert(task); + add_result?; if !added_exchanges .iter() .any(|new_exchange_info| new_exchange_info.exchange_id == exchange_id) diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 11f9f1aa..a70c489a 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -3206,6 +3206,27 @@ impl BlocklistAIController { ); } + /// Starts a newly created child on its root task. Shell initialization may still be + /// finishing when the bootstrap event arrives; it is not a command to monitor. + pub(crate) fn send_initial_child_agent_query( + &mut self, + query: String, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + self.send_user_query_in_conversation_internal( + query, + conversation_id, + None, + RunningCommandDetection::Skip, + HashMap::new(), + EntrypointType::AgentInitiated, + false, + None, + ctx, + ); + } + /// Sends one non-preemptive final assessment to the root task after a CLI monitor completes. /// /// This deliberately bypasses `send_query`: command completion must not cancel @@ -8339,8 +8360,11 @@ impl BlocklistAIController { }); } }, - // Failed outcomes are finalized by the projected InternalError event. - ProviderRunOutcome::Failed(_) => {} + ProviderRunOutcome::Failed(failure) => { + // Projection itself can fail before an exchange can display InternalError. + // The durable outcome must still release parents waiting for this child. + self.finalize_failed_provider_conversation(conversation_id, failure.message, ctx); + } ProviderRunOutcome::Cancelled { .. } => { let cancellation_reason = self.active_provider_runs[&conversation_id].cancellation_reason; @@ -8373,6 +8397,28 @@ impl BlocklistAIController { self.cleanup_active_provider_run(conversation_id, &stream_id, &response_stream, ctx); } + fn finalize_failed_provider_conversation( + &self, + conversation_id: AIConversationId, + message: String, + ctx: &mut ModelContext, + ) { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + if history_model + .conversation_status(&conversation_id) + .is_some_and(|status| status != &ConversationStatus::Error) + { + history_model.update_conversation_status_with_error( + self.terminal_surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + } + }); + } + fn fail_provider_startup( &mut self, conversation_id: AIConversationId, @@ -8391,7 +8437,7 @@ impl BlocklistAIController { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.mark_response_stream_completed_with_error( RenderableAIError::Other { - error_message: message, + error_message: message.clone(), will_attempt_resume: false, waiting_for_network: false, is_user_error: false, @@ -8403,6 +8449,7 @@ impl BlocklistAIController { ctx, ); }); + self.finalize_failed_provider_conversation(conversation_id, message, ctx); self.cleanup_active_provider_run(conversation_id, &stream_id, &response_stream, ctx); } @@ -10361,7 +10408,7 @@ fn get_running_command_for_conversation( conversation_id: AIConversationId, ) -> Option { let active_block = terminal_model.block_list().active_block(); - if !active_block.is_active_and_long_running() { + if !active_block.bootstrap_stage().is_done() || !active_block.is_active_and_long_running() { return None; } if active_block.is_agent_monitoring() diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index b4269358..600dfff9 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -35,6 +35,8 @@ use crate::ai::remote_logging::RemoteLogLevel; use crate::ai::runtime::ProviderRunProjection; use crate::persistence::model::{AcpConversationData, AgentBackend}; use crate::terminal::model::block::{BlockId, BlockState}; +use crate::terminal::model::bootstrap::BootstrapStage; +use crate::terminal::model::test_utils::TestBlockBuilder; use crate::test_util::settings::initialize_history_persistence_for_tests; use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view}; @@ -3812,3 +3814,79 @@ fn progressive_summary_keeps_retained_tool_results_paired() { let budget = super::estimated_history_tokens(&messages[2..]); assert_eq!(super::progressive_summary_split_point(&messages, budget), 1); } + +#[test] +fn shell_bootstrap_is_never_a_command_monitor_target() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |terminal, _ctx| { + let mut model = terminal.model.lock(); + let conversation_id = AIConversationId::new(); + for stage in [ + BootstrapStage::WarpInput, + BootstrapStage::ScriptExecution, + BootstrapStage::Bootstrapped, + BootstrapStage::PostBootstrapPrecmd, + ] { + *model.block_list_mut().active_block_mut() = + TestBlockBuilder::new().with_bootstrap_stage(stage).build(); + model.simulate_long_running_block("sleep 100", "shell startup output"); + assert!(model + .block_list() + .active_block() + .is_active_and_long_running()); + assert_eq!( + super::get_running_command_for_conversation(&model, conversation_id).is_some(), + stage == BootstrapStage::PostBootstrapPrecmd, + "bootstrap stage {stage:?}", + ); + } + }); + }); +} + +#[test] +fn failed_child_without_an_exchange_reaches_terminal_error() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |terminal, ctx| { + let terminal_id = terminal.id(); + let children = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let parent = history.start_new_conversation(terminal_id, false, false, false, ctx); + ["stakeholder", "architect", "developer"].map(|name| { + history.start_new_child_conversation( + terminal_id, + name.to_owned(), + parent, + None, + ctx, + ) + }) + }); + terminal.ai_controller().update(ctx, |controller, ctx| { + for child in [children[0], children[2]] { + controller.finalize_failed_provider_conversation( + child, + "projection failed: ExchangeNotFound".to_owned(), + ctx, + ); + } + }); + let history = BlocklistAIHistoryModel::as_ref(ctx); + for child in [children[0], children[2]] { + let conversation = history.conversation(&child).unwrap(); + assert_eq!(conversation.status(), &ConversationStatus::Error); + assert!(conversation + .status_error_message() + .unwrap() + .contains("ExchangeNotFound")); + } + assert_eq!( + history.conversation(&children[1]).unwrap().status(), + &ConversationStatus::InProgress + ); + }); + }); +} diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index d95c966e..f3b57b7f 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -16279,7 +16279,7 @@ impl TerminalView { return; } self.ai_controller.update(ctx, |controller, ctx| { - controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + controller.send_initial_child_agent_query(prompt, conversation_id, ctx); }); } pub fn has_pending_command_or_awaiting_completion(&self, ctx: &AppContext) -> bool {