diff --git a/AGENTS.md b/AGENTS.md index 30dbd559..14086fcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ Key invariants: - Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration - ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun` - Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools +- Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits ### Platform Setup - `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided. diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index ac34e26b..e4deffe1 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -355,8 +355,9 @@ impl BlocklistAIActionExecutor { let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone())); let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new()); let start_agent_executor = ctx.add_model(StartAgentExecutor::new); - let run_agents_executor = ctx - .add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); + let run_agents_executor = ctx.add_model(|ctx| { + RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx) + }); let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new()); let ask_user_question_executor = ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id)); diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index c3d823a1..d31f09aa 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -12,14 +12,17 @@ use ai::agent::action_result::{ }; use ai::agent::orchestration_config::OrchestrationConfig; use ai::skills::SkillReference; -use futures::future::BoxFuture; +use futures::future::{join_all, BoxFuture}; use futures::FutureExt; use galaxy_core::execution_mode::AppExecutionMode; use settings::Setting; use warp_cli::agent::Harness; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; -use super::start_agent::{StartAgentExecutor, StartAgentOutcome}; +use super::start_agent::{ + StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, + StartAgentWaitPolicy, +}; use super::{ child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput, @@ -80,6 +83,12 @@ pub enum RunAgentsExecutorEvent { SpawningFinished { action_id: AIAgentActionId, }, + ChildConversationCreated { + action_id: AIAgentActionId, + agent_name: String, + parent_conversation_id: AIConversationId, + child_conversation_id: AIConversationId, + }, } impl Entity for RunAgentsExecutor { @@ -90,7 +99,24 @@ impl RunAgentsExecutor { pub fn new( start_agent_executor: ModelHandle, terminal_view_id: EntityId, + ctx: &mut ModelContext, ) -> Self { + ctx.subscribe_to_model(&start_agent_executor, |_, _, event, ctx| { + if let StartAgentExecutorEvent::RunAgentsChildConversationCreated { + action_id, + agent_name, + parent_conversation_id, + child_conversation_id, + } = event + { + ctx.emit(RunAgentsExecutorEvent::ChildConversationCreated { + action_id: action_id.clone(), + agent_name: agent_name.clone(), + parent_conversation_id: *parent_conversation_id, + child_conversation_id: *child_conversation_id, + }); + } + }); Self { pending: HashMap::new(), launched_agents: HashMap::new(), @@ -103,6 +129,10 @@ impl RunAgentsExecutor { self.pending.contains_key(action_id) } + pub(crate) fn terminal_view_id(&self) -> EntityId { + self.terminal_view_id + } + /// Cancels a pending run so publication completion cannot fan out children. pub(super) fn cancel_execution( &mut self, @@ -361,8 +391,9 @@ impl RunAgentsExecutor { "execution_mode": start_agent_execution_mode_label(&mode), }), ); - let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| { + let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| { executor.dispatch( + action_id.clone(), cfg.name.clone(), prompt, mode, @@ -372,7 +403,7 @@ impl RunAgentsExecutor { exec_ctx, ) }); - slots.push(ChildSlot::Pending(recv)); + slots.push(ChildSlot::Pending(dispatch)); } let agent_run_configs_for_result = agent_run_configs.clone(); @@ -393,49 +424,9 @@ impl RunAgentsExecutor { ctx.spawn( async move { - let mut outcomes: Vec = Vec::with_capacity(slots.len()); - for (slot_index, slot) in slots.into_iter().enumerate() { - let kind = match slot { - ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error }, - ChildSlot::Pending(recv) => { - let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT); - match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout)) - .await - { - futures::future::Either::Left(( - Ok(StartAgentOutcome::Started { agent_id }), - _, - )) => RunAgentsAgentOutcomeKind::Launched { agent_id }, - futures::future::Either::Left(( - Ok(StartAgentOutcome::Completed { agent_id, .. }), - _, - )) => RunAgentsAgentOutcomeKind::Launched { agent_id }, - futures::future::Either::Left(( - Ok(StartAgentOutcome::Error(error)), - _, - )) => RunAgentsAgentOutcomeKind::Failed { error }, - futures::future::Either::Left((Err(_), _)) => { - RunAgentsAgentOutcomeKind::Failed { - error: "Cancelled before launch".to_string(), - } - } - futures::future::Either::Right((_, _)) => { - log::warn!( - "Agent spawn timed out after {} seconds", - SPAWN_TIMEOUT.as_secs() - ); - RunAgentsAgentOutcomeKind::Failed { - error: format!( - "Agent failed to start within {} seconds. \ - The harness binary may not be installed.", - SPAWN_TIMEOUT.as_secs() - ), - } - } - } - } - }; - #[cfg(not(target_family = "wasm"))] + let outcomes = join_all(slots.into_iter().map(resolve_child_slot)).await; + #[cfg(not(target_family = "wasm"))] + for (slot_index, kind) in outcomes.iter().enumerate() { log::info!( "RunAgents child launch outcome action_id={} parent_conversation_id={} \ agent_name={} slot_index={} outcome={}", @@ -446,9 +437,8 @@ impl RunAgentsExecutor { .map(String::as_str) .unwrap_or(""), slot_index, - run_agents_agent_outcome_kind_label(&kind) + run_agents_agent_outcome_kind_label(kind) ); - outcomes.push(kind); } outcomes }, @@ -651,7 +641,57 @@ fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'st enum ChildSlot { Failed(String), - Pending(async_channel::Receiver), + Pending(StartAgentDispatch), +} + +async fn resolve_child_slot(slot: ChildSlot) -> RunAgentsAgentOutcomeKind { + resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await +} + +async fn resolve_child_slot_with_timeout( + slot: ChildSlot, + spawn_timeout: Duration, +) -> RunAgentsAgentOutcomeKind { + let dispatch = match slot { + ChildSlot::Failed(error) => return RunAgentsAgentOutcomeKind::Failed { error }, + ChildSlot::Pending(dispatch) => dispatch, + }; + + let outcome = match dispatch.wait_policy { + StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(), + StartAgentWaitPolicy::Startup => { + let timeout = warpui::r#async::Timer::after(spawn_timeout); + match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout)) + .await + { + futures::future::Either::Left((outcome, _)) => outcome.ok(), + futures::future::Either::Right((_, _)) => { + log::warn!( + "Agent spawn timed out after {} seconds", + spawn_timeout.as_secs() + ); + return RunAgentsAgentOutcomeKind::Failed { + error: format!( + "Agent failed to start within {} seconds. \ + The harness binary may not be installed.", + spawn_timeout.as_secs() + ), + }; + } + } + } + }; + + match outcome { + Some(StartAgentOutcome::Started { agent_id }) + | Some(StartAgentOutcome::Completed { agent_id, .. }) => { + RunAgentsAgentOutcomeKind::Launched { agent_id } + } + Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error }, + None => RunAgentsAgentOutcomeKind::Failed { + error: "Child agent was cancelled before completion".to_string(), + }, + } } fn approved_orchestration_config_can_autoexecute( diff --git a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs index 04e105af..b2e541af 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs @@ -358,6 +358,113 @@ fn validate_request_rejects_remote_dispatch() { ); } +#[test] +fn completion_slots_are_polled_concurrently_and_preserve_request_order() { + App::test((), |_app| async move { + let (first_sender, first_receiver) = async_channel::bounded(1); + let (second_sender, second_receiver) = async_channel::bounded(1); + let slots = vec![ + ChildSlot::Pending(StartAgentDispatch { + receiver: first_receiver, + wait_policy: StartAgentWaitPolicy::Completion, + }), + ChildSlot::Pending(StartAgentDispatch { + receiver: second_receiver, + wait_policy: StartAgentWaitPolicy::Completion, + }), + ChildSlot::Failed("prelaunch failure".to_string()), + ]; + let mut outcomes = + Box::pin(join_all(slots.into_iter().map(|slot| { + resolve_child_slot_with_timeout(slot, Duration::from_millis(1)) + }))); + + second_sender + .try_send(StartAgentOutcome::Completed { + agent_id: "second-agent".to_string(), + output: "done".to_string(), + }) + .unwrap(); + assert!(futures::poll!(&mut outcomes).is_pending()); + assert!( + !second_sender.is_full(), + "join_all should poll and drain the second slot while the first is pending" + ); + + first_sender + .try_send(StartAgentOutcome::Error("first failed".to_string())) + .unwrap(); + let outcomes = outcomes.await; + + assert!(matches!( + &outcomes[0], + RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed" + )); + assert!(matches!( + &outcomes[1], + RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "second-agent" + )); + assert!(matches!( + &outcomes[2], + RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure" + )); + }); +} + +#[test] +fn completion_wait_ignores_spawn_timeout() { + App::test((), |_app| async move { + let (sender, receiver) = async_channel::bounded(1); + let completion = Box::pin(resolve_child_slot_with_timeout( + ChildSlot::Pending(StartAgentDispatch { + receiver, + wait_policy: StartAgentWaitPolicy::Completion, + }), + Duration::from_millis(1), + )); + let wait = warpui::r#async::Timer::after(Duration::from_millis(20)); + + let completion = match futures::future::select(completion, Box::pin(wait)).await { + futures::future::Either::Left((outcome, _)) => { + panic!("completion wait unexpectedly resolved before child completion: {outcome:?}") + } + futures::future::Either::Right((_, completion)) => completion, + }; + sender + .try_send(StartAgentOutcome::Completed { + agent_id: "child-agent".to_string(), + output: "done".to_string(), + }) + .unwrap(); + + assert!(matches!( + completion.await, + RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "child-agent" + )); + }); +} + +#[test] +fn startup_wait_retains_spawn_timeout() { + App::test((), |_app| async move { + let (_sender, receiver) = async_channel::bounded(1); + let outcome = resolve_child_slot_with_timeout( + ChildSlot::Pending(StartAgentDispatch { + receiver, + wait_policy: StartAgentWaitPolicy::Startup, + }), + Duration::from_millis(1), + ) + .await; + + assert!(matches!( + outcome, + RunAgentsAgentOutcomeKind::Failed { error } + if error.contains("Agent failed to start within") + )); + }); +} + fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState { initialize_settings_for_tests_with_mode(app, mode, false); let global_resource_handles = GlobalResourceHandles::mock(app); @@ -389,8 +496,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) }); let start_agent_executor = app.add_model(StartAgentExecutor::new); - let executor = - app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); + let executor = app.add_model(|ctx| { + RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx) + }); RunAgentsTestState { conversation_id, diff --git a/app/src/ai/blocklist/action_model/execute/start_agent.rs b/app/src/ai/blocklist/action_model/execute/start_agent.rs index 0eb8adba..4aa82346 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent.rs @@ -30,10 +30,23 @@ pub enum StartAgentOutcome { agent_id: String, output: String, }, - /// An error occurred while starting the agent. + /// An error occurred while starting or running the agent. Error(String), } +/// Determines whether a dispatch receiver acknowledges startup or waits for a +/// direct-provider child to reach a terminal state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartAgentWaitPolicy { + Startup, + Completion, +} + +pub struct StartAgentDispatch { + pub receiver: async_channel::Receiver, + pub wait_policy: StartAgentWaitPolicy, +} + fn invalid_local_child_harness_error(harness_type: &str) -> String { let harness_name = harness_type.trim(); if harness_name.is_empty() { @@ -118,9 +131,10 @@ pub struct StartAgentRequest { } struct PendingStartAgent { - /// Present for standalone StartAgent tool calls. RunAgents dispatches use - /// the same executor but do not have a one-to-one StartAgent action card. - action_id: Option, + action_id: AIAgentActionId, + /// Present when RunAgents owns this dispatch. Standalone StartAgent calls + /// use the action id only for their one-to-one inline child panel. + run_agents_child_name: Option, parent_conversation_id: AIConversationId, /// Set once the child conversation is synchronously created. child_conversation_id: Option, @@ -128,7 +142,7 @@ struct PendingStartAgent { /// Direct Bedrock/OpenAI parents do not have a server run id or an /// orchestration event stream. Keep the tool call open until their local /// child finishes, then return the child's output inline. - wait_for_completion: bool, + wait_policy: StartAgentWaitPolicy, } pub struct StartAgentExecutor { @@ -161,34 +175,33 @@ impl StartAgentExecutor { child_conversation_id: AIConversationId, ctx: &mut ModelContext, ) { - let direct_provider_panel_link = { + let child_link_event = { let Some(pending) = self.pending.get_mut(&request_id) else { return; }; pending.child_conversation_id = Some(child_conversation_id); - if pending.wait_for_completion { - pending.action_id.clone().map(|action_id| { - ( - action_id, - pending.parent_conversation_id, - child_conversation_id, - ) + if let Some(agent_name) = pending.run_agents_child_name.clone() { + Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated { + action_id: pending.action_id.clone(), + agent_name, + parent_conversation_id: pending.parent_conversation_id, + child_conversation_id, }) + } else if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) { + Some( + StartAgentExecutorEvent::DirectProviderChildConversationCreated { + action_id: pending.action_id.clone(), + parent_conversation_id: pending.parent_conversation_id, + child_conversation_id, + }, + ) } else { None } }; - if let Some((action_id, parent_conversation_id, child_conversation_id)) = - direct_provider_panel_link - { - ctx.emit( - StartAgentExecutorEvent::DirectProviderChildConversationCreated { - action_id, - parent_conversation_id, - child_conversation_id, - }, - ); + if let Some(event) = child_link_event { + ctx.emit(event); } self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx); } @@ -274,14 +287,15 @@ impl StartAgentExecutor { return; }; let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg)); - // A child that reaches `complete_pending_as_error` never obtained an - // agent id, so it failed at the launch stage. Clean up its hidden - // pane + conversation so the orchestration pill bar does not retain a - // dead chip — but only for terminal failures, leaving recoverable - // `Blocked` startup states (e.g. awaiting GitHub auth) intact. - let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&child_conversation_id) - .is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status())); + // Only startup acknowledgements may clean up a conversation that never + // initialized. Direct-provider completion waits preserve the terminal + // child so its transcript and failure remain inspectable. + let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup) + && BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_conversation_id) + .is_some_and(|conversation| { + should_cleanup_failed_child_launch(conversation.status()) + }); if should_cleanup { ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id: child_conversation_id, @@ -300,23 +314,49 @@ impl StartAgentExecutor { else { return; }; - if let Some(error_msg) = start_agent_error_message_for_status( - conversation.status(), - conversation.status_error_message().as_deref(), - ) { - self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx); - return; - } - let wait_for_completion = self + let wait_policy = self .pending .get(&request_id) - .is_some_and(|pending| pending.wait_for_completion); - if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) { - self.complete_pending_as_completed(request_id, child_conversation_id, ctx); - return; - } - if conversation.orchestration_agent_id().is_some() { - self.complete_pending_as_started(request_id, child_conversation_id, ctx); + .map(|pending| pending.wait_policy); + match wait_policy { + Some(StartAgentWaitPolicy::Completion) => match conversation.status() { + ConversationStatus::Success => { + self.complete_pending_as_completed(request_id, child_conversation_id, ctx); + } + ConversationStatus::Error | ConversationStatus::Cancelled => { + let error_msg = direct_child_error_message_for_status( + conversation.status(), + conversation.status_error_message().as_deref(), + ) + .expect("terminal direct child status should produce an error"); + self.complete_pending_as_error( + request_id, + child_conversation_id, + error_msg, + ctx, + ); + } + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::Blocked { .. } + | ConversationStatus::WaitingForEvents => {} + }, + Some(StartAgentWaitPolicy::Startup) => { + if let Some(error_msg) = start_agent_startup_error_message_for_status( + conversation.status(), + conversation.status_error_message().as_deref(), + ) { + self.complete_pending_as_error( + request_id, + child_conversation_id, + error_msg, + ctx, + ); + } else if conversation.orchestration_agent_id().is_some() { + self.complete_pending_as_started(request_id, child_conversation_id, ctx); + } + } + None => {} } } @@ -349,6 +389,22 @@ impl StartAgentExecutor { } => { self.record_child_conversation(*request_id, *conversation_id, ctx); } + BlocklistAIHistoryEvent::RemoveConversation { + conversation_id, .. + } + | BlocklistAIHistoryEvent::DeletedConversation { + conversation_id, .. + } => { + let Some(request_id) = self.find_pending_by_child(conversation_id) else { + return; + }; + let Some(pending) = self.pending.remove(&request_id) else { + return; + }; + let _ = pending.sender.try_send(StartAgentOutcome::Error( + "Child agent conversation was removed by the user.".to_string(), + )); + } BlocklistAIHistoryEvent::StartedNewConversation { .. } | BlocklistAIHistoryEvent::CreatedSubtask { .. } | BlocklistAIHistoryEvent::UpgradedTask { .. } @@ -361,8 +417,6 @@ impl StartAgentExecutor { | BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::SplitConversation { .. } - | BlocklistAIHistoryEvent::RemoveConversation { .. } - | BlocklistAIHistoryEvent::DeletedConversation { .. } | BlocklistAIHistoryEvent::RestoredConversations { .. } | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } @@ -543,18 +597,23 @@ impl StartAgentExecutor { // In local mode (no parent_run_id), block until the child finishes // so the parent model receives the child's output as the tool result. - let wait_for_completion = parent_run_id.is_none(); + let wait_policy = if parent_run_id.is_none() { + StartAgentWaitPolicy::Completion + } else { + StartAgentWaitPolicy::Startup + }; let (sender, receiver) = async_channel::bounded(1); let request_id = self.next_request_id(); self.pending.insert( request_id, PendingStartAgent { - action_id: Some(action_id), + action_id, + run_agents_child_name: None, parent_conversation_id, child_conversation_id: None, sender, - wait_for_completion, + wait_policy, }, ); @@ -599,6 +658,7 @@ impl StartAgentExecutor { #[allow(clippy::too_many_arguments)] pub fn dispatch( &mut self, + action_id: AIAgentActionId, name: String, prompt: String, execution_mode: StartAgentExecutionMode, @@ -606,11 +666,19 @@ impl StartAgentExecutor { parent_conversation_id: AIConversationId, parent_run_id: Option, ctx: &mut ModelContext, - ) -> async_channel::Receiver { + ) -> StartAgentDispatch { + let wait_policy = if parent_run_id.is_none() { + StartAgentWaitPolicy::Completion + } else { + StartAgentWaitPolicy::Startup + }; let (sender, receiver) = async_channel::bounded(1); if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) { let _ = sender.try_send(StartAgentOutcome::Error(error)); - return receiver; + return StartAgentDispatch { + receiver, + wait_policy, + }; } let (prompt, execution_mode) = @@ -620,11 +688,12 @@ impl StartAgentExecutor { self.pending.insert( request_id, PendingStartAgent { - action_id: None, + action_id, + run_agents_child_name: Some(name.clone()), parent_conversation_id, child_conversation_id: None, sender, - wait_for_completion: parent_run_id.is_none(), + wait_policy, }, ); ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new( @@ -638,7 +707,10 @@ impl StartAgentExecutor { parent_run_id, }, ))); - receiver + StartAgentDispatch { + receiver, + wait_policy, + } } pub(super) fn preprocess_action( @@ -682,7 +754,7 @@ fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool { } } -fn start_agent_error_message_for_status( +fn start_agent_startup_error_message_for_status( status: &ConversationStatus, error_message: Option<&str>, ) -> Option { @@ -717,6 +789,26 @@ fn start_agent_error_message_for_status( } } +fn direct_child_error_message_for_status( + status: &ConversationStatus, + error_message: Option<&str>, +) -> Option { + match status { + ConversationStatus::Error => Some( + error_message + .filter(|message| !message.trim().is_empty()) + .unwrap_or("Child agent failed") + .to_string(), + ), + ConversationStatus::Cancelled => Some("Child agent was cancelled by the user.".to_string()), + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::Success + | ConversationStatus::Blocked { .. } + | ConversationStatus::WaitingForEvents => None, + } +} + impl Entity for StartAgentExecutor { type Event = StartAgentExecutorEvent; } @@ -731,6 +823,14 @@ pub enum StartAgentExecutorEvent { parent_conversation_id: AIConversationId, child_conversation_id: AIConversationId, }, + /// A RunAgents child conversation is available for live status and + /// navigation in the owning action card. + RunAgentsChildConversationCreated { + action_id: AIAgentActionId, + agent_name: String, + parent_conversation_id: AIConversationId, + child_conversation_id: AIConversationId, + }, /// A child agent failed at the launch stage (never started a server-side /// run). The owning terminal view removes its hidden pane and conversation /// so the orchestration pill bar does not retain a dead chip. diff --git a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs index 95bebb6b..d0bb5103 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs @@ -35,6 +35,15 @@ impl Entity for CapturedStartAgentPrompts { type Event = (); } +#[derive(Default)] +struct CapturedRunAgentsChildLinks( + Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>, +); + +impl Entity for CapturedRunAgentsChildLinks { + type Event = (); +} + fn capture_start_agent_prompts( app: &mut App, executor: &ModelHandle, @@ -74,6 +83,32 @@ fn capture_direct_provider_child_links( captured } +fn capture_run_agents_child_links( + app: &mut App, + executor: &ModelHandle, +) -> ModelHandle { + let captured = app.add_model(|_| CapturedRunAgentsChildLinks::default()); + captured.update(app, |_, ctx| { + ctx.subscribe_to_model(executor, |captured, _, event, _ctx| { + if let StartAgentExecutorEvent::RunAgentsChildConversationCreated { + action_id, + agent_name, + parent_conversation_id, + child_conversation_id, + } = event + { + captured.0.push(( + action_id.clone(), + agent_name.clone(), + *parent_conversation_id, + *child_conversation_id, + )); + } + }); + }); + captured +} + fn build_start_agent_action( version: StartAgentVersion, execution_mode: StartAgentExecutionMode, @@ -199,8 +234,9 @@ fn dispatch_denies_child_conversation_defense_in_depth() { conversation_id }); - let receiver = executor.update(&mut app, |executor, ctx| { + let dispatch = executor.update(&mut app, |executor, ctx| { executor.dispatch( + AIAgentActionId::from("run-agents-action".to_string()), "grandchild".to_string(), "Do more work".to_string(), StartAgentExecutionMode::local_with_defaults(), @@ -212,7 +248,7 @@ fn dispatch_denies_child_conversation_defense_in_depth() { }); assert!(matches!( - receiver.try_recv(), + dispatch.receiver.try_recv(), Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers") )); executor.read(&app, |executor, _ctx| { @@ -685,6 +721,231 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() { }); } +struct PendingDirectProviderChild { + action_id: AIAgentActionId, + parent_conversation_id: AIConversationId, + history_model: ModelHandle, + executor: ModelHandle, + captured_cleanup: ModelHandle, + direct_links: ModelHandle, + run_agents_links: ModelHandle, + terminal_view_id: EntityId, + child_conversation_id: AIConversationId, + dispatch: StartAgentDispatch, +} + +fn dispatch_pending_direct_provider_child(app: &mut App) -> PendingDirectProviderChild { + initialize_history_persistence_for_tests(app); + let terminal_view_id = EntityId::new(); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let executor = app.add_model(StartAgentExecutor::new); + let captured_cleanup = app.add_model(|_| CapturedCleanupEvents::default()); + captured_cleanup.update(app, |_, ctx| { + ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| { + if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event { + captured.0.push(*conversation_id); + } + }); + }); + let direct_links = capture_direct_provider_child_links(app, &executor); + let run_agents_links = capture_run_agents_child_links(app, &executor); + let parent_conversation_id = history_model.update(app, |history_model, ctx| { + history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let action_id = AIAgentActionId::from("run-agents-action".to_string()); + let dispatch = executor.update(app, |executor, ctx| { + executor.dispatch( + action_id.clone(), + "child".to_string(), + "Investigate the failure".to_string(), + StartAgentExecutionMode::local_with_defaults(), + None, + parent_conversation_id, + None, + ctx, + ) + }); + let child_conversation_id = history_model.update(app, |history_model, ctx| { + history_model.start_new_child_conversation( + terminal_view_id, + "child".to_string(), + parent_conversation_id, + None, + ctx, + ) + }); + history_model.update(app, |history_model, ctx| { + history_model.record_new_conversation_request_complete( + FIRST_REQUEST_ID, + child_conversation_id, + ctx, + ); + }); + + PendingDirectProviderChild { + action_id, + parent_conversation_id, + history_model, + executor, + captured_cleanup, + direct_links, + run_agents_links, + terminal_view_id, + child_conversation_id, + dispatch, + } +} + +#[test] +fn direct_provider_nonterminal_states_remain_pending_until_cancelled() { + App::test((), |mut app| async move { + let state = dispatch_pending_direct_provider_child(&mut app); + assert_eq!(state.dispatch.wait_policy, StartAgentWaitPolicy::Completion); + + for status in [ + ConversationStatus::Blocked { + blocked_action: "Waiting for user input".to_string(), + }, + ConversationStatus::TransientError, + ConversationStatus::WaitingForEvents, + ] { + state.history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + state.terminal_view_id, + state.child_conversation_id, + status, + ctx, + ); + }); + assert!(matches!( + state.dispatch.receiver.try_recv(), + Err(async_channel::TryRecvError::Empty) + )); + state.executor.read(&app, |executor, _| { + assert!(executor.pending.contains_key(&FIRST_REQUEST_ID)); + }); + } + + state.history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + state.terminal_view_id, + state.child_conversation_id, + ConversationStatus::Cancelled, + ctx, + ); + }); + + assert!(matches!( + state.dispatch.receiver.try_recv(), + Ok(StartAgentOutcome::Error(error)) + if error == "Child agent was cancelled by the user." + )); + state.executor.read(&app, |executor, _| { + assert!(executor.pending.is_empty()); + }); + state.captured_cleanup.read(&app, |captured, _| { + assert!(captured.0.is_empty()); + }); + }); +} + +#[test] +fn direct_provider_error_preserves_child_for_inspection() { + App::test((), |mut app| async move { + let state = dispatch_pending_direct_provider_child(&mut app); + state.history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status_with_error( + state.terminal_view_id, + state.child_conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other("Child execution failed", false)), + ctx, + ); + }); + + assert!(matches!( + state.dispatch.receiver.try_recv(), + Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed" + )); + state.captured_cleanup.read(&app, |captured, _| { + assert!(captured.0.is_empty()); + }); + state.history_model.read(&app, |history_model, _| { + assert!(history_model + .conversation(&state.child_conversation_id) + .is_some()); + }); + }); +} + +#[test] +fn removing_direct_provider_child_resolves_pending_wait() { + App::test((), |mut app| async move { + let state = dispatch_pending_direct_provider_child(&mut app); + state.history_model.update(&mut app, |history_model, ctx| { + history_model.remove_conversation( + state.child_conversation_id, + state.terminal_view_id, + ctx, + ); + }); + + assert!(matches!( + state.dispatch.receiver.try_recv(), + Ok(StartAgentOutcome::Error(error)) + if error == "Child agent conversation was removed by the user." + )); + state.executor.read(&app, |executor, _| { + assert!(executor.pending.is_empty()); + }); + }); +} + +#[test] +fn deleting_direct_provider_child_resolves_pending_wait() { + App::test((), |mut app| async move { + let state = dispatch_pending_direct_provider_child(&mut app); + state.history_model.update(&mut app, |history_model, ctx| { + history_model.delete_conversation( + state.child_conversation_id, + Some(state.terminal_view_id), + ctx, + ); + }); + + assert!(matches!( + state.dispatch.receiver.try_recv(), + Ok(StartAgentOutcome::Error(error)) + if error == "Child agent conversation was removed by the user." + )); + state.executor.read(&app, |executor, _| { + assert!(executor.pending.is_empty()); + }); + }); +} + +#[test] +fn run_agents_dispatch_publishes_only_run_agents_child_link() { + App::test((), |mut app| async move { + let state = dispatch_pending_direct_provider_child(&mut app); + + state.direct_links.read(&app, |captured, _| { + assert!(captured.0.is_empty()); + }); + state.run_agents_links.read(&app, |captured, _| { + assert_eq!( + captured.0, + vec![( + state.action_id.clone(), + "child".to_string(), + state.parent_conversation_id, + state.child_conversation_id, + )] + ); + }); + }); +} + #[test] fn execute_waits_for_direct_provider_child_and_returns_its_output() { App::test((), |mut app| async move { @@ -738,7 +999,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() { .pending .get(&FIRST_REQUEST_ID) .expect("direct child should remain pending until completion"); - assert!(pending.wait_for_completion); + assert_eq!(pending.wait_policy, StartAgentWaitPolicy::Completion); }); history_model.update(&mut app, |history_model, ctx| { diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index cf5d5695..5854389e 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -1672,19 +1672,14 @@ impl BlocklistAIController { } if *terminal_surface_id == me.terminal_surface_id => { me.schedule_restored_provider_runs(conversation_ids, ctx); } - BlocklistAIHistoryEvent::UpdatedConversationStatus { - terminal_surface_id, - new_status, - .. - } if *terminal_surface_id == me.terminal_surface_id && new_status.is_done() => { - let pending_parents = me - .pending_child_blocked_follow_ups - .iter() - .copied() - .collect::>(); - for parent_id in pending_parents { - me.maybe_resume_child_blocked_follow_up(parent_id, ctx); - } + BlocklistAIHistoryEvent::UpdatedConversationStatus { new_status, .. } + if new_status.is_done() => + { + me.resume_pending_child_blocked_follow_ups(ctx); + } + BlocklistAIHistoryEvent::RemoveConversation { .. } + | BlocklistAIHistoryEvent::DeletedConversation { .. } => { + me.resume_pending_child_blocked_follow_ups(ctx); } BlocklistAIHistoryEvent::StartedNewConversation { .. } | BlocklistAIHistoryEvent::CreatedSubtask { .. } @@ -1699,8 +1694,6 @@ impl BlocklistAIController { | BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::SplitConversation { .. } - | BlocklistAIHistoryEvent::RemoveConversation { .. } - | BlocklistAIHistoryEvent::DeletedConversation { .. } | BlocklistAIHistoryEvent::RestoredConversations { .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } @@ -3064,6 +3057,17 @@ impl BlocklistAIController { self.pending_passive_follow_ups.remove(&conversation_id); } + fn resume_pending_child_blocked_follow_ups(&mut self, ctx: &mut ModelContext) { + let pending_parents = self + .pending_child_blocked_follow_ups + .iter() + .copied() + .collect::>(); + for parent_id in pending_parents { + self.maybe_resume_child_blocked_follow_up(parent_id, ctx); + } + } + fn maybe_resume_child_blocked_follow_up( &mut self, conversation_id: AIConversationId, diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index 3732bb88..8bc1f39a 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -1202,6 +1202,69 @@ fn active_descendant_conversation_ids_filters_done_children() { }); } +#[test] +fn child_removal_and_deletion_resume_deferred_parent_follow_up() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + for delete_child in [false, true] { + let (parent_id, child_id) = terminal.update(&mut app, |terminal, ctx| { + let terminal_surface_id = terminal.id(); + let history_model = BlocklistAIHistoryModel::handle(ctx); + let parent_id = history_model.update(ctx, |history_model, ctx| { + history_model.start_new_conversation( + terminal_surface_id, + false, + false, + false, + ctx, + ) + }); + let child_id = history_model.update(ctx, |history_model, ctx| { + history_model.start_new_child_conversation( + terminal_surface_id, + "child".to_string(), + parent_id, + None, + ctx, + ) + }); + terminal.ai_controller().update(ctx, |controller, ctx| { + controller.send_follow_up_for_conversation(parent_id, ctx); + assert!(controller + .pending_child_blocked_follow_ups + .contains(&parent_id)); + }); + (parent_id, child_id) + }); + + terminal.update(&mut app, |terminal, ctx| { + let terminal_surface_id = terminal.id(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + if delete_child { + history_model.delete_conversation(child_id, Some(terminal_surface_id), ctx); + } else { + history_model.remove_conversation(child_id, terminal_surface_id, ctx); + } + }); + }); + futures_lite::future::yield_now().await; + + terminal.update(&mut app, |terminal, ctx| { + terminal.ai_controller().read(ctx, |controller, _| { + assert!( + !controller + .pending_child_blocked_follow_ups + .contains(&parent_id), + "removing the final active child should unblock its parent" + ); + }); + }); + } + }); +} + #[test] fn acp_backend_model_identity_does_not_claim_a_provider_model() { assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None); diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index e043fa3c..62c5a2a2 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -7,14 +7,15 @@ use std::collections::HashMap; use std::rc::Rc; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; -use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; +use ai::agent::action_result::{RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsResult}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use ai::skills::SkillReference; use galaxy_core::send_telemetry_from_ctx; use pathfinder_geometry::vector::vec2f; use warpui::elements::{ Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, - OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap, + MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, + Stack, Text, Wrap, }; use warpui::keymap::FixedBinding; use warpui::{ @@ -22,12 +23,16 @@ use warpui::{ ViewHandle, }; -use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle}; use crate::ai::agent::{icons, AIAgentActionId, AIAgentActionResultType}; use crate::ai::blocklist::action_model::{ AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, }; +use crate::ai::blocklist::agent_view::orchestration_conversation_links::{ + conversation_id_for_agent_id, conversation_navigation_card_with_icon, + dispatch_focus_or_open_child_agent_pane, +}; use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agent_pill; use crate::ai::blocklist::block::model::AIBlockModel; use crate::ai::blocklist::block::view_impl::WithContentItemSpacing; @@ -50,6 +55,7 @@ use crate::ai::blocklist::telemetry::{ OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, RunAgentsCardDecisionEvent, }; +use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -213,11 +219,90 @@ pub enum RunAgentsCardViewEvent { RejectRequested, } +#[derive(Clone)] +struct RunAgentsChildState { + name: String, + conversation_id: Option, + removed: bool, + mouse_state: MouseStateHandle, +} + +impl RunAgentsChildState { + fn new(name: String) -> Self { + Self { + name, + conversation_id: None, + removed: false, + mouse_state: MouseStateHandle::default(), + } + } +} + +fn sync_run_agents_children( + children: &mut Vec, + configs: &[RunAgentsAgentRunConfig], +) { + let mut previous_children = std::mem::take(children); + *children = configs + .iter() + .map(|config| { + previous_children + .iter() + .position(|child| child.name == config.name) + .map(|index| previous_children.remove(index)) + .unwrap_or_else(|| RunAgentsChildState::new(config.name.clone())) + }) + .collect(); +} + +fn link_run_agents_child( + children: &mut [RunAgentsChildState], + agent_name: &str, + conversation_id: AIConversationId, +) -> bool { + let child_index = children + .iter() + .position(|child| child.name == agent_name && child.conversation_id.is_none()) + .or_else(|| children.iter().position(|child| child.name == agent_name)); + let Some(child_index) = child_index else { + return false; + }; + let child = &mut children[child_index]; + child.conversation_id = Some(conversation_id); + child.removed = false; + true +} + +fn has_run_agents_child( + children: &[RunAgentsChildState], + conversation_id: AIConversationId, +) -> bool { + children + .iter() + .any(|child| child.conversation_id == Some(conversation_id)) +} + +fn mark_run_agents_child_removed( + children: &mut [RunAgentsChildState], + conversation_id: AIConversationId, +) -> bool { + let Some(child) = children + .iter_mut() + .find(|child| child.conversation_id == Some(conversation_id)) + else { + return false; + }; + child.removed = true; + true +} + pub struct RunAgentsCardView { action_id: AIAgentActionId, state: RunAgentsEditState, handles: RunAgentsCardHandles, spawning: Option, + children: Vec, + terminal_view_id: warpui::EntityId, /// Retained for interactive defaults and telemetry about plan-sourced /// orchestration state. active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, @@ -303,6 +388,12 @@ impl RunAgentsCardView { ctx: &mut ViewContext, ) -> Self { let state = RunAgentsEditState::from_request(request); + let children = state + .agent_run_configs + .iter() + .map(|config| RunAgentsChildState::new(config.name.clone())) + .collect(); + let terminal_view_id = run_agents_executor.as_ref(ctx).terminal_view_id(); // Snapshot the raw incoming request so we can diff against the // edited state at Accept time. let original_tool_call_request = request.clone(); @@ -364,8 +455,36 @@ impl RunAgentsCardView { me.spawning = None; ctx.notify(); } + RunAgentsExecutorEvent::ChildConversationCreated { + action_id, + agent_name, + child_conversation_id, + .. + } if action_id == &action_id_for_subscription => { + me.link_child_conversation(agent_name, *child_conversation_id); + ctx.notify(); + } RunAgentsExecutorEvent::SpawningStarted { .. } - | RunAgentsExecutorEvent::SpawningFinished { .. } => {} + | RunAgentsExecutorEvent::SpawningFinished { .. } + | RunAgentsExecutorEvent::ChildConversationCreated { .. } => {} + }); + + let history_model = BlocklistAIHistoryModel::handle(ctx); + ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event { + BlocklistAIHistoryEvent::UpdatedConversationStatus { + conversation_id, .. + } if me.has_child_conversation(*conversation_id) => { + ctx.notify(); + } + BlocklistAIHistoryEvent::RemoveConversation { + conversation_id, .. + } + | BlocklistAIHistoryEvent::DeletedConversation { + conversation_id, .. + } if me.mark_child_removed(*conversation_id) => { + ctx.notify(); + } + _ => {} }); // Re-render when this action finishes or becomes blocked. @@ -481,6 +600,8 @@ impl RunAgentsCardView { ..Default::default() }, spawning: None, + children, + terminal_view_id, active_config, is_accept_menu_open: false, accept_menu, @@ -543,6 +664,7 @@ impl RunAgentsCardView { || self.state.orch.model_id != new_state.orch.model_id || self.state.orch.execution_mode != new_state.orch.execution_mode; self.state = new_state; + self.sync_configured_children(); if harness_or_model_changed { // Repopulate pickers and re-arm auto-open for the newly- // streamed harness. @@ -555,6 +677,26 @@ impl RunAgentsCardView { } } + fn sync_configured_children(&mut self) { + sync_run_agents_children(&mut self.children, &self.state.agent_run_configs); + } + + fn link_child_conversation(&mut self, agent_name: &str, conversation_id: AIConversationId) { + if !link_run_agents_child(&mut self.children, agent_name, conversation_id) { + log::warn!( + "RunAgentsCardView: received child conversation for unknown agent '{agent_name}'" + ); + } + } + + fn has_child_conversation(&self, conversation_id: AIConversationId) -> bool { + has_run_agents_child(&self.children, conversation_id) + } + + fn mark_child_removed(&mut self, conversation_id: AIConversationId) -> bool { + mark_run_agents_child_removed(&mut self.children, conversation_id) + } + /// Validates and dispatches the resolved request. pub fn accept(&mut self, ctx: &mut ViewContext) { self.handle_accept(ctx); @@ -957,7 +1099,13 @@ impl View for RunAgentsCardView { if let Some(AIActionStatus::Finished(result)) = &status { if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result { - return render_terminal_state(orchestrate_result, appearance, app); + return render_terminal_state( + orchestrate_result, + &self.children, + self.terminal_view_id, + appearance, + app, + ); } log::error!( "Unexpected action result type for orchestrate: {:?}", @@ -969,13 +1117,25 @@ impl View for RunAgentsCardView { // In-flight dispatch: check both spawning snapshot and action // status because the event arrives one tick after the status. if let Some(snapshot) = &self.spawning { - return render_spawning_card(snapshot, appearance, app); + return render_spawning_card( + snapshot, + &self.children, + self.terminal_view_id, + appearance, + app, + ); } if matches!(status, Some(AIActionStatus::RunningAsync)) { let snapshot = RunAgentsSpawningSnapshot { agent_count: self.state.agent_run_configs.len(), }; - return render_spawning_card(&snapshot, appearance, app); + return render_spawning_card( + &snapshot, + &self.children, + self.terminal_view_id, + appearance, + app, + ); } // Restored-from-history: dispatch state is lost, render as @@ -1352,11 +1512,21 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box Box { let (label, kind) = format_terminal_state(result); - render_status_only_card(label, appearance, kind, app) + render_status_card( + label, + appearance, + kind, + children, + Some(result), + Some(terminal_view_id), + app, + ) } pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) { @@ -1424,6 +1594,8 @@ pub(crate) enum StatusKind { fn render_spawning_card( snapshot: &RunAgentsSpawningSnapshot, + children: &[RunAgentsChildState], + terminal_view_id: warpui::EntityId, appearance: &Appearance, app: &AppContext, ) -> Box { @@ -1433,7 +1605,15 @@ fn render_spawning_card( } else { format!("Spawning {total} agents\u{2026}") }; - render_status_only_card(label, appearance, StatusKind::Spawning, app) + render_status_card( + label, + appearance, + StatusKind::Spawning, + children, + None, + Some(terminal_view_id), + app, + ) } fn render_status_only_card( @@ -1441,6 +1621,19 @@ fn render_status_only_card( appearance: &Appearance, kind: StatusKind, app: &AppContext, +) -> Box { + render_status_card(label, appearance, kind, &[], None, None, app) +} + +#[allow(clippy::too_many_arguments)] +fn render_status_card( + label: String, + appearance: &Appearance, + kind: StatusKind, + children: &[RunAgentsChildState], + result: Option<&RunAgentsResult>, + terminal_view_id: Option, + app: &AppContext, ) -> Box { let theme = appearance.theme(); let icon = match kind { @@ -1452,7 +1645,8 @@ fn render_status_only_card( StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(), StatusKind::Cancelled => inline_action_icons::cancelled_icon(appearance).finish(), }; - let row = render_requested_action_row_for_text( + let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + column.add_child(render_requested_action_row_for_text( label.into(), appearance.ui_font_family(), Some(icon), @@ -1460,8 +1654,49 @@ fn render_status_only_card( false, false, app, - ); - Container::new(row) + )); + + if !children.is_empty() { + let Some(terminal_view_id) = terminal_view_id else { + log::error!("RunAgentsCardView: child rows require a terminal view id"); + return Empty::new().finish(); + }; + let outcomes = match result { + Some(RunAgentsResult::Launched { agents, .. }) => Some(agents.as_slice()), + Some( + RunAgentsResult::Denied { .. } + | RunAgentsResult::Failure { .. } + | RunAgentsResult::Cancelled, + ) + | None => None, + }; + let mut child_column = + Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + for (index, child) in children.iter().enumerate() { + let outcome = outcomes.and_then(|agents| agents.get(index)); + child_column.add_child( + Container::new(render_run_agents_child_row( + child, + outcome, + result.is_some(), + terminal_view_id, + appearance, + app, + )) + .with_margin_top(4.) + .finish(), + ); + } + column.add_child( + Container::new(child_column.finish()) + .with_padding_left(8.) + .with_padding_right(8.) + .with_padding_bottom(8.) + .finish(), + ); + } + + Container::new(column.finish()) .with_background_color(blended_colors::neutral_2(theme)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .finish() @@ -1469,6 +1704,89 @@ fn render_status_only_card( .finish() } +fn render_run_agents_child_row( + child: &RunAgentsChildState, + outcome: Option<&RunAgentsAgentOutcome>, + is_terminal: bool, + terminal_view_id: warpui::EntityId, + appearance: &Appearance, + app: &AppContext, +) -> Box { + let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind { + RunAgentsAgentOutcomeKind::Launched { agent_id } => { + conversation_id_for_agent_id(agent_id, app) + } + RunAgentsAgentOutcomeKind::Failed { .. } => None, + }); + let conversation_id = child.conversation_id.or(outcome_conversation_id); + + if !child.removed { + if let Some(conversation_id) = conversation_id { + if let Some(conversation) = + BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) + { + let status = conversation.status(); + let status_icon = + status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard); + let mouse_state = child.mouse_state.clone(); + return conversation_navigation_card_with_icon( + Some(status_icon), + child.name.clone(), + Some(status.to_string()), + move |ctx, app, _| { + dispatch_focus_or_open_child_agent_pane( + conversation_id, + terminal_view_id, + ctx, + app, + ); + }, + mouse_state, + true, + None, + app, + ); + } + } + } + + let (status, label) = if child.removed { + (ConversationStatus::Cancelled, "Removed".to_string()) + } else if let Some(outcome) = outcome { + match &outcome.kind { + RunAgentsAgentOutcomeKind::Launched { .. } => { + (ConversationStatus::Success, "Started".to_string()) + } + RunAgentsAgentOutcomeKind::Failed { error } => ( + ConversationStatus::Error, + if error.trim().is_empty() { + "Failed".to_string() + } else { + format!("Failed: {error}") + }, + ), + } + } else if is_terminal { + (ConversationStatus::Cancelled, "Not started".to_string()) + } else { + ( + ConversationStatus::InProgress, + "Starting\u{2026}".to_string(), + ) + }; + let (icon, color) = + status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard); + render_requested_action_row_for_text( + format!("{}: {label}", child.name).into(), + appearance.ui_font_family(), + Some(icon.to_warpui_icon(color.into()).finish()), + None, + false, + false, + app, + ) +} + fn render_editor( state: &RunAgentsEditState, handles: &RunAgentsCardHandles, diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index a9ef589d..5946d81f 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -8,7 +8,11 @@ use ai::agent::action_result::{ use ai::skills::SkillReference; use warp_util::local_or_remote_path::LocalOrRemotePath; -use super::RunAgentsEditState; +use super::{ + has_run_agents_child, link_run_agents_child, mark_run_agents_child_removed, + sync_run_agents_children, RunAgentsChildState, RunAgentsEditState, +}; +use crate::ai::agent::conversation::AIConversationId; use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState; fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest { @@ -250,6 +254,57 @@ fn to_request_preserves_fields_but_normalizes_execution_to_local() { assert_eq!(round_tripped.plan_id, req.plan_id); } +#[test] +fn live_child_links_and_removal_survive_streaming_config_sync() { + let first_id = AIConversationId::new(); + let replacement_id = AIConversationId::new(); + let mut children = vec![ + RunAgentsChildState::new("alpha".to_string()), + RunAgentsChildState::new("beta".to_string()), + ]; + + assert!(link_run_agents_child(&mut children, "alpha", first_id)); + assert!(has_run_agents_child(&children, first_id)); + assert!(mark_run_agents_child_removed(&mut children, first_id)); + assert!(children[0].removed); + + let configs = vec![ + RunAgentsAgentRunConfig { + name: "gamma".to_string(), + prompt: "new work".to_string(), + title: String::new(), + }, + RunAgentsAgentRunConfig { + name: "alpha".to_string(), + prompt: "updated work".to_string(), + title: String::new(), + }, + ]; + sync_run_agents_children(&mut children, &configs); + + assert_eq!( + children + .iter() + .map(|child| child.name.as_str()) + .collect::>(), + vec!["gamma", "alpha"] + ); + assert_eq!(children[1].conversation_id, Some(first_id)); + assert!(children[1].removed); + assert!(link_run_agents_child( + &mut children, + "alpha", + replacement_id + )); + assert_eq!(children[1].conversation_id, Some(replacement_id)); + assert!(!children[1].removed); + assert!(!link_run_agents_child( + &mut children, + "missing", + AIConversationId::new() + )); +} + mod format_terminal_state_tests { use super::super::{format_terminal_state, StatusKind}; use super::*; diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index f5e6675b..edde1e42 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -7705,6 +7705,9 @@ impl TerminalView { // AI blocks subscribe directly to this executor event so the // StartAgent card can render its live child transcript. } + StartAgentExecutorEvent::RunAgentsChildConversationCreated { .. } => { + // RunAgentsExecutor forwards this linkage to its owning card. + } StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { // The child failed at launch and never started a server-side // run; reuse the Kill path to drop its hidden pane and