Wait for direct-provider child agents
This commit is contained in:
@@ -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
|
- 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`
|
- 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
|
- 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
|
### 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.
|
- `./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.
|
||||||
|
|||||||
@@ -355,8 +355,9 @@ impl BlocklistAIActionExecutor {
|
|||||||
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
|
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
|
||||||
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
|
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
|
||||||
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
|
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
|
||||||
let run_agents_executor = ctx
|
let run_agents_executor = ctx.add_model(|ctx| {
|
||||||
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
|
||||||
|
});
|
||||||
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
||||||
let ask_user_question_executor =
|
let ask_user_question_executor =
|
||||||
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
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::agent::orchestration_config::OrchestrationConfig;
|
||||||
use ai::skills::SkillReference;
|
use ai::skills::SkillReference;
|
||||||
use futures::future::BoxFuture;
|
use futures::future::{join_all, BoxFuture};
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use galaxy_core::execution_mode::AppExecutionMode;
|
use galaxy_core::execution_mode::AppExecutionMode;
|
||||||
use settings::Setting;
|
use settings::Setting;
|
||||||
use warp_cli::agent::Harness;
|
use warp_cli::agent::Harness;
|
||||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||||
|
|
||||||
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
|
use super::start_agent::{
|
||||||
|
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
|
||||||
|
StartAgentWaitPolicy,
|
||||||
|
};
|
||||||
use super::{
|
use super::{
|
||||||
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||||
PreprocessActionInput,
|
PreprocessActionInput,
|
||||||
@@ -80,6 +83,12 @@ pub enum RunAgentsExecutorEvent {
|
|||||||
SpawningFinished {
|
SpawningFinished {
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
},
|
},
|
||||||
|
ChildConversationCreated {
|
||||||
|
action_id: AIAgentActionId,
|
||||||
|
agent_name: String,
|
||||||
|
parent_conversation_id: AIConversationId,
|
||||||
|
child_conversation_id: AIConversationId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity for RunAgentsExecutor {
|
impl Entity for RunAgentsExecutor {
|
||||||
@@ -90,7 +99,24 @@ impl RunAgentsExecutor {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||||
terminal_view_id: EntityId,
|
terminal_view_id: EntityId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
) -> 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 {
|
Self {
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
launched_agents: HashMap::new(),
|
launched_agents: HashMap::new(),
|
||||||
@@ -103,6 +129,10 @@ impl RunAgentsExecutor {
|
|||||||
self.pending.contains_key(action_id)
|
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.
|
/// Cancels a pending run so publication completion cannot fan out children.
|
||||||
pub(super) fn cancel_execution(
|
pub(super) fn cancel_execution(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -361,8 +391,9 @@ impl RunAgentsExecutor {
|
|||||||
"execution_mode": start_agent_execution_mode_label(&mode),
|
"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(
|
executor.dispatch(
|
||||||
|
action_id.clone(),
|
||||||
cfg.name.clone(),
|
cfg.name.clone(),
|
||||||
prompt,
|
prompt,
|
||||||
mode,
|
mode,
|
||||||
@@ -372,7 +403,7 @@ impl RunAgentsExecutor {
|
|||||||
exec_ctx,
|
exec_ctx,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
slots.push(ChildSlot::Pending(recv));
|
slots.push(ChildSlot::Pending(dispatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
let agent_run_configs_for_result = agent_run_configs.clone();
|
let agent_run_configs_for_result = agent_run_configs.clone();
|
||||||
@@ -393,49 +424,9 @@ impl RunAgentsExecutor {
|
|||||||
|
|
||||||
ctx.spawn(
|
ctx.spawn(
|
||||||
async move {
|
async move {
|
||||||
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
|
let outcomes = join_all(slots.into_iter().map(resolve_child_slot)).await;
|
||||||
for (slot_index, slot) in slots.into_iter().enumerate() {
|
#[cfg(not(target_family = "wasm"))]
|
||||||
let kind = match slot {
|
for (slot_index, kind) in outcomes.iter().enumerate() {
|
||||||
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"))]
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
|
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
|
||||||
agent_name={} slot_index={} outcome={}",
|
agent_name={} slot_index={} outcome={}",
|
||||||
@@ -446,9 +437,8 @@ impl RunAgentsExecutor {
|
|||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or("<unknown>"),
|
.unwrap_or("<unknown>"),
|
||||||
slot_index,
|
slot_index,
|
||||||
run_agents_agent_outcome_kind_label(&kind)
|
run_agents_agent_outcome_kind_label(kind)
|
||||||
);
|
);
|
||||||
outcomes.push(kind);
|
|
||||||
}
|
}
|
||||||
outcomes
|
outcomes
|
||||||
},
|
},
|
||||||
@@ -651,7 +641,57 @@ fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'st
|
|||||||
|
|
||||||
enum ChildSlot {
|
enum ChildSlot {
|
||||||
Failed(String),
|
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(
|
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 {
|
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
||||||
initialize_settings_for_tests_with_mode(app, mode, false);
|
initialize_settings_for_tests_with_mode(app, mode, false);
|
||||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
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)
|
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||||
});
|
});
|
||||||
let start_agent_executor = app.add_model(StartAgentExecutor::new);
|
let start_agent_executor = app.add_model(StartAgentExecutor::new);
|
||||||
let executor =
|
let executor = app.add_model(|ctx| {
|
||||||
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
|
||||||
|
});
|
||||||
|
|
||||||
RunAgentsTestState {
|
RunAgentsTestState {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
|||||||
@@ -30,10 +30,23 @@ pub enum StartAgentOutcome {
|
|||||||
agent_id: String,
|
agent_id: String,
|
||||||
output: String,
|
output: String,
|
||||||
},
|
},
|
||||||
/// An error occurred while starting the agent.
|
/// An error occurred while starting or running the agent.
|
||||||
Error(String),
|
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 {
|
fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
||||||
let harness_name = harness_type.trim();
|
let harness_name = harness_type.trim();
|
||||||
if harness_name.is_empty() {
|
if harness_name.is_empty() {
|
||||||
@@ -118,9 +131,10 @@ pub struct StartAgentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct PendingStartAgent {
|
struct PendingStartAgent {
|
||||||
/// Present for standalone StartAgent tool calls. RunAgents dispatches use
|
action_id: AIAgentActionId,
|
||||||
/// the same executor but do not have a one-to-one StartAgent action card.
|
/// Present when RunAgents owns this dispatch. Standalone StartAgent calls
|
||||||
action_id: Option<AIAgentActionId>,
|
/// use the action id only for their one-to-one inline child panel.
|
||||||
|
run_agents_child_name: Option<String>,
|
||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
/// Set once the child conversation is synchronously created.
|
/// Set once the child conversation is synchronously created.
|
||||||
child_conversation_id: Option<AIConversationId>,
|
child_conversation_id: Option<AIConversationId>,
|
||||||
@@ -128,7 +142,7 @@ struct PendingStartAgent {
|
|||||||
/// Direct Bedrock/OpenAI parents do not have a server run id or an
|
/// Direct Bedrock/OpenAI parents do not have a server run id or an
|
||||||
/// orchestration event stream. Keep the tool call open until their local
|
/// orchestration event stream. Keep the tool call open until their local
|
||||||
/// child finishes, then return the child's output inline.
|
/// child finishes, then return the child's output inline.
|
||||||
wait_for_completion: bool,
|
wait_policy: StartAgentWaitPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StartAgentExecutor {
|
pub struct StartAgentExecutor {
|
||||||
@@ -161,34 +175,33 @@ impl StartAgentExecutor {
|
|||||||
child_conversation_id: AIConversationId,
|
child_conversation_id: AIConversationId,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
let direct_provider_panel_link = {
|
let child_link_event = {
|
||||||
let Some(pending) = self.pending.get_mut(&request_id) else {
|
let Some(pending) = self.pending.get_mut(&request_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
pending.child_conversation_id = Some(child_conversation_id);
|
pending.child_conversation_id = Some(child_conversation_id);
|
||||||
if pending.wait_for_completion {
|
if let Some(agent_name) = pending.run_agents_child_name.clone() {
|
||||||
pending.action_id.clone().map(|action_id| {
|
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
|
||||||
(
|
action_id: pending.action_id.clone(),
|
||||||
action_id,
|
agent_name,
|
||||||
pending.parent_conversation_id,
|
parent_conversation_id: pending.parent_conversation_id,
|
||||||
child_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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((action_id, parent_conversation_id, child_conversation_id)) =
|
if let Some(event) = child_link_event {
|
||||||
direct_provider_panel_link
|
ctx.emit(event);
|
||||||
{
|
|
||||||
ctx.emit(
|
|
||||||
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
|
|
||||||
action_id,
|
|
||||||
parent_conversation_id,
|
|
||||||
child_conversation_id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
||||||
}
|
}
|
||||||
@@ -274,14 +287,15 @@ impl StartAgentExecutor {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
||||||
// A child that reaches `complete_pending_as_error` never obtained an
|
// Only startup acknowledgements may clean up a conversation that never
|
||||||
// agent id, so it failed at the launch stage. Clean up its hidden
|
// initialized. Direct-provider completion waits preserve the terminal
|
||||||
// pane + conversation so the orchestration pill bar does not retain a
|
// child so its transcript and failure remain inspectable.
|
||||||
// dead chip — but only for terminal failures, leaving recoverable
|
let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup)
|
||||||
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
|
&& BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
|
.conversation(&child_conversation_id)
|
||||||
.conversation(&child_conversation_id)
|
.is_some_and(|conversation| {
|
||||||
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
|
should_cleanup_failed_child_launch(conversation.status())
|
||||||
|
});
|
||||||
if should_cleanup {
|
if should_cleanup {
|
||||||
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
||||||
conversation_id: child_conversation_id,
|
conversation_id: child_conversation_id,
|
||||||
@@ -300,23 +314,49 @@ impl StartAgentExecutor {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(error_msg) = start_agent_error_message_for_status(
|
let wait_policy = self
|
||||||
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
|
|
||||||
.pending
|
.pending
|
||||||
.get(&request_id)
|
.get(&request_id)
|
||||||
.is_some_and(|pending| pending.wait_for_completion);
|
.map(|pending| pending.wait_policy);
|
||||||
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) {
|
match wait_policy {
|
||||||
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
|
Some(StartAgentWaitPolicy::Completion) => match conversation.status() {
|
||||||
return;
|
ConversationStatus::Success => {
|
||||||
}
|
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
|
||||||
if conversation.orchestration_agent_id().is_some() {
|
}
|
||||||
self.complete_pending_as_started(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);
|
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::StartedNewConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||||
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
||||||
@@ -361,8 +417,6 @@ impl StartAgentExecutor {
|
|||||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
||||||
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::RemoveConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::DeletedConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
||||||
@@ -543,18 +597,23 @@ impl StartAgentExecutor {
|
|||||||
|
|
||||||
// In local mode (no parent_run_id), block until the child finishes
|
// 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.
|
// 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 (sender, receiver) = async_channel::bounded(1);
|
||||||
let request_id = self.next_request_id();
|
let request_id = self.next_request_id();
|
||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
request_id,
|
request_id,
|
||||||
PendingStartAgent {
|
PendingStartAgent {
|
||||||
action_id: Some(action_id),
|
action_id,
|
||||||
|
run_agents_child_name: None,
|
||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
child_conversation_id: None,
|
child_conversation_id: None,
|
||||||
sender,
|
sender,
|
||||||
wait_for_completion,
|
wait_policy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -599,6 +658,7 @@ impl StartAgentExecutor {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn dispatch(
|
pub fn dispatch(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
action_id: AIAgentActionId,
|
||||||
name: String,
|
name: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
execution_mode: StartAgentExecutionMode,
|
execution_mode: StartAgentExecutionMode,
|
||||||
@@ -606,11 +666,19 @@ impl StartAgentExecutor {
|
|||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
parent_run_id: Option<String>,
|
parent_run_id: Option<String>,
|
||||||
ctx: &mut ModelContext<Self>,
|
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);
|
let (sender, receiver) = async_channel::bounded(1);
|
||||||
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||||
let _ = sender.try_send(StartAgentOutcome::Error(error));
|
let _ = sender.try_send(StartAgentOutcome::Error(error));
|
||||||
return receiver;
|
return StartAgentDispatch {
|
||||||
|
receiver,
|
||||||
|
wait_policy,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let (prompt, execution_mode) =
|
let (prompt, execution_mode) =
|
||||||
@@ -620,11 +688,12 @@ impl StartAgentExecutor {
|
|||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
request_id,
|
request_id,
|
||||||
PendingStartAgent {
|
PendingStartAgent {
|
||||||
action_id: None,
|
action_id,
|
||||||
|
run_agents_child_name: Some(name.clone()),
|
||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
child_conversation_id: None,
|
child_conversation_id: None,
|
||||||
sender,
|
sender,
|
||||||
wait_for_completion: parent_run_id.is_none(),
|
wait_policy,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||||
@@ -638,7 +707,10 @@ impl StartAgentExecutor {
|
|||||||
parent_run_id,
|
parent_run_id,
|
||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
receiver
|
StartAgentDispatch {
|
||||||
|
receiver,
|
||||||
|
wait_policy,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn preprocess_action(
|
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,
|
status: &ConversationStatus,
|
||||||
error_message: Option<&str>,
|
error_message: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> 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 {
|
impl Entity for StartAgentExecutor {
|
||||||
type Event = StartAgentExecutorEvent;
|
type Event = StartAgentExecutorEvent;
|
||||||
}
|
}
|
||||||
@@ -731,6 +823,14 @@ pub enum StartAgentExecutorEvent {
|
|||||||
parent_conversation_id: AIConversationId,
|
parent_conversation_id: AIConversationId,
|
||||||
child_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
|
/// A child agent failed at the launch stage (never started a server-side
|
||||||
/// run). The owning terminal view removes its hidden pane and conversation
|
/// run). The owning terminal view removes its hidden pane and conversation
|
||||||
/// so the orchestration pill bar does not retain a dead chip.
|
/// so the orchestration pill bar does not retain a dead chip.
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ impl Entity for CapturedStartAgentPrompts {
|
|||||||
type Event = ();
|
type Event = ();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CapturedRunAgentsChildLinks(
|
||||||
|
Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl Entity for CapturedRunAgentsChildLinks {
|
||||||
|
type Event = ();
|
||||||
|
}
|
||||||
|
|
||||||
fn capture_start_agent_prompts(
|
fn capture_start_agent_prompts(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
executor: &ModelHandle<StartAgentExecutor>,
|
executor: &ModelHandle<StartAgentExecutor>,
|
||||||
@@ -74,6 +83,32 @@ fn capture_direct_provider_child_links(
|
|||||||
captured
|
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(
|
fn build_start_agent_action(
|
||||||
version: StartAgentVersion,
|
version: StartAgentVersion,
|
||||||
execution_mode: StartAgentExecutionMode,
|
execution_mode: StartAgentExecutionMode,
|
||||||
@@ -199,8 +234,9 @@ fn dispatch_denies_child_conversation_defense_in_depth() {
|
|||||||
conversation_id
|
conversation_id
|
||||||
});
|
});
|
||||||
|
|
||||||
let receiver = executor.update(&mut app, |executor, ctx| {
|
let dispatch = executor.update(&mut app, |executor, ctx| {
|
||||||
executor.dispatch(
|
executor.dispatch(
|
||||||
|
AIAgentActionId::from("run-agents-action".to_string()),
|
||||||
"grandchild".to_string(),
|
"grandchild".to_string(),
|
||||||
"Do more work".to_string(),
|
"Do more work".to_string(),
|
||||||
StartAgentExecutionMode::local_with_defaults(),
|
StartAgentExecutionMode::local_with_defaults(),
|
||||||
@@ -212,7 +248,7 @@ fn dispatch_denies_child_conversation_defense_in_depth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
receiver.try_recv(),
|
dispatch.receiver.try_recv(),
|
||||||
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
|
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
|
||||||
));
|
));
|
||||||
executor.read(&app, |executor, _ctx| {
|
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]
|
#[test]
|
||||||
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
@@ -738,7 +999,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() {
|
|||||||
.pending
|
.pending
|
||||||
.get(&FIRST_REQUEST_ID)
|
.get(&FIRST_REQUEST_ID)
|
||||||
.expect("direct child should remain pending until completion");
|
.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| {
|
history_model.update(&mut app, |history_model, ctx| {
|
||||||
|
|||||||
@@ -1672,19 +1672,14 @@ impl BlocklistAIController {
|
|||||||
} if *terminal_surface_id == me.terminal_surface_id => {
|
} if *terminal_surface_id == me.terminal_surface_id => {
|
||||||
me.schedule_restored_provider_runs(conversation_ids, ctx);
|
me.schedule_restored_provider_runs(conversation_ids, ctx);
|
||||||
}
|
}
|
||||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
BlocklistAIHistoryEvent::UpdatedConversationStatus { new_status, .. }
|
||||||
terminal_surface_id,
|
if new_status.is_done() =>
|
||||||
new_status,
|
{
|
||||||
..
|
me.resume_pending_child_blocked_follow_ups(ctx);
|
||||||
} if *terminal_surface_id == me.terminal_surface_id && new_status.is_done() => {
|
}
|
||||||
let pending_parents = me
|
BlocklistAIHistoryEvent::RemoveConversation { .. }
|
||||||
.pending_child_blocked_follow_ups
|
| BlocklistAIHistoryEvent::DeletedConversation { .. } => {
|
||||||
.iter()
|
me.resume_pending_child_blocked_follow_ups(ctx);
|
||||||
.copied()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
for parent_id in pending_parents {
|
|
||||||
me.maybe_resume_child_blocked_follow_up(parent_id, ctx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||||
@@ -1699,8 +1694,6 @@ impl BlocklistAIController {
|
|||||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
||||||
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
||||||
| BlocklistAIHistoryEvent::RemoveConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::DeletedConversation { .. }
|
|
||||||
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
||||||
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
||||||
@@ -3064,6 +3057,17 @@ impl BlocklistAIController {
|
|||||||
self.pending_passive_follow_ups.remove(&conversation_id);
|
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(
|
fn maybe_resume_child_blocked_follow_up(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn acp_backend_model_identity_does_not_claim_a_provider_model() {
|
fn acp_backend_model_identity_does_not_claim_a_provider_model() {
|
||||||
assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None);
|
assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None);
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ use std::collections::HashMap;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
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::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
|
||||||
use ai::skills::SkillReference;
|
use ai::skills::SkillReference;
|
||||||
use galaxy_core::send_telemetry_from_ctx;
|
use galaxy_core::send_telemetry_from_ctx;
|
||||||
use pathfinder_geometry::vector::vec2f;
|
use pathfinder_geometry::vector::vec2f;
|
||||||
use warpui::elements::{
|
use warpui::elements::{
|
||||||
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
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::keymap::FixedBinding;
|
||||||
use warpui::{
|
use warpui::{
|
||||||
@@ -22,12 +23,16 @@ use warpui::{
|
|||||||
ViewHandle,
|
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::agent::{icons, AIAgentActionId, AIAgentActionResultType};
|
||||||
use crate::ai::blocklist::action_model::{
|
use crate::ai::blocklist::action_model::{
|
||||||
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor,
|
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor,
|
||||||
RunAgentsExecutorEvent, RunAgentsSpawningSnapshot,
|
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::agent_view::orchestration_pill_bar::render_static_agent_pill;
|
||||||
use crate::ai::blocklist::block::model::AIBlockModel;
|
use crate::ai::blocklist::block::model::AIBlockModel;
|
||||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||||
@@ -50,6 +55,7 @@ use crate::ai::blocklist::telemetry::{
|
|||||||
OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision,
|
OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision,
|
||||||
RunAgentsCardDecisionEvent,
|
RunAgentsCardDecisionEvent,
|
||||||
};
|
};
|
||||||
|
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||||
use crate::ai::connected_self_hosted_workers::{
|
use crate::ai::connected_self_hosted_workers::{
|
||||||
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
|
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
|
||||||
};
|
};
|
||||||
@@ -213,11 +219,90 @@ pub enum RunAgentsCardViewEvent {
|
|||||||
RejectRequested,
|
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 {
|
pub struct RunAgentsCardView {
|
||||||
action_id: AIAgentActionId,
|
action_id: AIAgentActionId,
|
||||||
state: RunAgentsEditState,
|
state: RunAgentsEditState,
|
||||||
handles: RunAgentsCardHandles,
|
handles: RunAgentsCardHandles,
|
||||||
spawning: Option<RunAgentsSpawningSnapshot>,
|
spawning: Option<RunAgentsSpawningSnapshot>,
|
||||||
|
children: Vec<RunAgentsChildState>,
|
||||||
|
terminal_view_id: warpui::EntityId,
|
||||||
/// Retained for interactive defaults and telemetry about plan-sourced
|
/// Retained for interactive defaults and telemetry about plan-sourced
|
||||||
/// orchestration state.
|
/// orchestration state.
|
||||||
active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>,
|
active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>,
|
||||||
@@ -303,6 +388,12 @@ impl RunAgentsCardView {
|
|||||||
ctx: &mut ViewContext<Self>,
|
ctx: &mut ViewContext<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let state = RunAgentsEditState::from_request(request);
|
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
|
// Snapshot the raw incoming request so we can diff against the
|
||||||
// edited state at Accept time.
|
// edited state at Accept time.
|
||||||
let original_tool_call_request = request.clone();
|
let original_tool_call_request = request.clone();
|
||||||
@@ -364,8 +455,36 @@ impl RunAgentsCardView {
|
|||||||
me.spawning = None;
|
me.spawning = None;
|
||||||
ctx.notify();
|
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::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.
|
// Re-render when this action finishes or becomes blocked.
|
||||||
@@ -481,6 +600,8 @@ impl RunAgentsCardView {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
spawning: None,
|
spawning: None,
|
||||||
|
children,
|
||||||
|
terminal_view_id,
|
||||||
active_config,
|
active_config,
|
||||||
is_accept_menu_open: false,
|
is_accept_menu_open: false,
|
||||||
accept_menu,
|
accept_menu,
|
||||||
@@ -543,6 +664,7 @@ impl RunAgentsCardView {
|
|||||||
|| self.state.orch.model_id != new_state.orch.model_id
|
|| self.state.orch.model_id != new_state.orch.model_id
|
||||||
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
|
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
|
||||||
self.state = new_state;
|
self.state = new_state;
|
||||||
|
self.sync_configured_children();
|
||||||
if harness_or_model_changed {
|
if harness_or_model_changed {
|
||||||
// Repopulate pickers and re-arm auto-open for the newly-
|
// Repopulate pickers and re-arm auto-open for the newly-
|
||||||
// streamed harness.
|
// 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.
|
/// Validates and dispatches the resolved request.
|
||||||
pub fn accept(&mut self, ctx: &mut ViewContext<Self>) {
|
pub fn accept(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
self.handle_accept(ctx);
|
self.handle_accept(ctx);
|
||||||
@@ -957,7 +1099,13 @@ impl View for RunAgentsCardView {
|
|||||||
|
|
||||||
if let Some(AIActionStatus::Finished(result)) = &status {
|
if let Some(AIActionStatus::Finished(result)) = &status {
|
||||||
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
|
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!(
|
log::error!(
|
||||||
"Unexpected action result type for orchestrate: {:?}",
|
"Unexpected action result type for orchestrate: {:?}",
|
||||||
@@ -969,13 +1117,25 @@ impl View for RunAgentsCardView {
|
|||||||
// In-flight dispatch: check both spawning snapshot and action
|
// In-flight dispatch: check both spawning snapshot and action
|
||||||
// status because the event arrives one tick after the status.
|
// status because the event arrives one tick after the status.
|
||||||
if let Some(snapshot) = &self.spawning {
|
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)) {
|
if matches!(status, Some(AIActionStatus::RunningAsync)) {
|
||||||
let snapshot = RunAgentsSpawningSnapshot {
|
let snapshot = RunAgentsSpawningSnapshot {
|
||||||
agent_count: self.state.agent_run_configs.len(),
|
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
|
// 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(
|
fn render_terminal_state(
|
||||||
result: &RunAgentsResult,
|
result: &RunAgentsResult,
|
||||||
|
children: &[RunAgentsChildState],
|
||||||
|
terminal_view_id: warpui::EntityId,
|
||||||
appearance: &Appearance,
|
appearance: &Appearance,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let (label, kind) = format_terminal_state(result);
|
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) {
|
pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) {
|
||||||
@@ -1424,6 +1594,8 @@ pub(crate) enum StatusKind {
|
|||||||
|
|
||||||
fn render_spawning_card(
|
fn render_spawning_card(
|
||||||
snapshot: &RunAgentsSpawningSnapshot,
|
snapshot: &RunAgentsSpawningSnapshot,
|
||||||
|
children: &[RunAgentsChildState],
|
||||||
|
terminal_view_id: warpui::EntityId,
|
||||||
appearance: &Appearance,
|
appearance: &Appearance,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
@@ -1433,7 +1605,15 @@ fn render_spawning_card(
|
|||||||
} else {
|
} else {
|
||||||
format!("Spawning {total} agents\u{2026}")
|
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(
|
fn render_status_only_card(
|
||||||
@@ -1441,6 +1621,19 @@ fn render_status_only_card(
|
|||||||
appearance: &Appearance,
|
appearance: &Appearance,
|
||||||
kind: StatusKind,
|
kind: StatusKind,
|
||||||
app: &AppContext,
|
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> {
|
) -> Box<dyn Element> {
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
let icon = match kind {
|
let icon = match kind {
|
||||||
@@ -1452,7 +1645,8 @@ fn render_status_only_card(
|
|||||||
StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(),
|
StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(),
|
||||||
StatusKind::Cancelled => inline_action_icons::cancelled_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(),
|
label.into(),
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
Some(icon),
|
Some(icon),
|
||||||
@@ -1460,8 +1654,49 @@ fn render_status_only_card(
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
app,
|
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_background_color(blended_colors::neutral_2(theme))
|
||||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||||
.finish()
|
.finish()
|
||||||
@@ -1469,6 +1704,89 @@ fn render_status_only_card(
|
|||||||
.finish()
|
.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(
|
fn render_editor(
|
||||||
state: &RunAgentsEditState,
|
state: &RunAgentsEditState,
|
||||||
handles: &RunAgentsCardHandles,
|
handles: &RunAgentsCardHandles,
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use ai::agent::action_result::{
|
|||||||
use ai::skills::SkillReference;
|
use ai::skills::SkillReference;
|
||||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
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;
|
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
||||||
|
|
||||||
fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest {
|
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);
|
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 {
|
mod format_terminal_state_tests {
|
||||||
use super::super::{format_terminal_state, StatusKind};
|
use super::super::{format_terminal_state, StatusKind};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -7705,6 +7705,9 @@ impl TerminalView {
|
|||||||
// AI blocks subscribe directly to this executor event so the
|
// AI blocks subscribe directly to this executor event so the
|
||||||
// StartAgent card can render its live child transcript.
|
// StartAgent card can render its live child transcript.
|
||||||
}
|
}
|
||||||
|
StartAgentExecutorEvent::RunAgentsChildConversationCreated { .. } => {
|
||||||
|
// RunAgentsExecutor forwards this linkage to its owning card.
|
||||||
|
}
|
||||||
StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => {
|
StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => {
|
||||||
// The child failed at launch and never started a server-side
|
// The child failed at launch and never started a server-side
|
||||||
// run; reuse the Kill path to drop its hidden pane and
|
// run; reuse the Kill path to drop its hidden pane and
|
||||||
|
|||||||
Reference in New Issue
Block a user