Wait for direct-provider child agents

This commit is contained in:
2026-08-15 19:23:28 -05:00
parent d10deb80a2
commit 93d6172072
11 changed files with 1096 additions and 142 deletions
+3 -2
View File
@@ -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));
@@ -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<StartAgentExecutor>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> 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<RunAgentsAgentOutcomeKind> = 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("<unknown>"),
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<StartAgentOutcome>),
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(
@@ -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,
@@ -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<StartAgentOutcome>,
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<AIAgentActionId>,
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<String>,
parent_conversation_id: AIConversationId,
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
@@ -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<Self>,
) {
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<String>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> {
) -> 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<String> {
@@ -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<String> {
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.
@@ -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<StartAgentExecutor>,
@@ -74,6 +83,32 @@ fn capture_direct_provider_child_links(
captured
}
fn capture_run_agents_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedRunAgentsChildLinks> {
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<BlocklistAIHistoryModel>,
executor: ModelHandle<StartAgentExecutor>,
captured_cleanup: ModelHandle<CapturedCleanupEvents>,
direct_links: ModelHandle<CapturedDirectProviderChildLinks>,
run_agents_links: ModelHandle<CapturedRunAgentsChildLinks>,
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| {
+19 -15
View File
@@ -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::<Vec<_>>();
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<Self>) {
let pending_parents = self
.pending_child_blocked_follow_ups
.iter()
.copied()
.collect::<Vec<_>>();
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,
+63
View File
@@ -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);
@@ -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<AIConversationId>,
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<RunAgentsChildState>,
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<RunAgentsSpawningSnapshot>,
children: Vec<RunAgentsChildState>,
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>,
) -> 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>) {
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<dy
fn render_terminal_state(
result: &RunAgentsResult,
children: &[RunAgentsChildState],
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
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<dyn Element> {
@@ -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<dyn Element> {
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<warpui::EntityId>,
app: &AppContext,
) -> Box<dyn Element> {
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<dyn Element> {
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,
@@ -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<_>>(),
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::*;
+3
View File
@@ -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