Lots of changes... not done yet.

This commit is contained in:
Ryan Ward
2026-08-17 18:19:37 -05:00
parent b5f3290d1a
commit 56e3b51d48
55 changed files with 4494 additions and 1098 deletions
+178 -110
View File
@@ -31,7 +31,7 @@ pub use execute::{
ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent,
RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor,
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, StartAgentWaitPolicy,
};
use futures::future::{join_all, BoxFuture};
use galaxy_agent_core::{
@@ -355,6 +355,45 @@ fn sort_action_results_by_order(
results.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
}
fn action_result_for_conversation<'a>(
finished_action_results: &'a HashMap<AIConversationId, Vec<Arc<AIAgentActionResult>>>,
provider_finished_action_results: &'a HashMap<
(AIConversationId, ExternalWorkId),
Vec<Arc<AIAgentActionResult>>,
>,
past_action_results: &'a HashMap<(AIConversationId, AIAgentActionId), Arc<AIAgentActionResult>>,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&'a Arc<AIAgentActionResult>> {
finished_action_results
.get(&conversation_id)
.into_iter()
.chain(provider_finished_action_results.iter().filter_map(
|((result_conversation_id, _), results)| {
(*result_conversation_id == conversation_id).then_some(results)
},
))
.flat_map(|results| results.iter())
.find(|result| &result.id == action_id)
.or_else(|| past_action_results.get(&(conversation_id, action_id.clone())))
}
fn pending_action_status(
pending_actions: &HashMap<AIConversationId, VecDeque<AIAgentAction>>,
running_actions: &HashMap<AIConversationId, RunningActions>,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
is_view_only: bool,
) -> Option<AIActionStatus> {
let actions = pending_actions.get(&conversation_id)?;
let index = actions.iter().position(|action| &action.id == action_id)?;
if index == 0 && !is_view_only && !running_actions.contains_key(&conversation_id) {
Some(AIActionStatus::Blocked)
} else {
Some(AIActionStatus::Queued)
}
}
fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
let status = if permission_denied {
ToolResultStatus::Denied
@@ -587,6 +626,9 @@ fn action_result_failure_summary(result: &AIAgentActionResultType) -> Option<Str
AIAgentActionResultType::RunAgents(RunAgentsResult::Failure { error }) => {
Some(error.clone())
}
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { message, .. },
) => Some(message.clone()),
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
@@ -734,7 +776,7 @@ pub struct BlocklistAIActionModel {
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
/// Past actions and their corresponding statuses from previous AI exchanges.
past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>,
past_action_results: HashMap<(AIConversationId, AIAgentActionId), Arc<AIAgentActionResult>>,
/// The ID of the terminal view this controller is associated with.
terminal_view_id: EntityId,
@@ -774,6 +816,7 @@ impl BlocklistAIActionModel {
let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id);
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id: *conversation_id,
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
@@ -863,6 +906,7 @@ impl BlocklistAIActionModel {
);
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
execution_ref: self.provider_tool_execution_ref(conversation_id, action_id),
});
}
@@ -988,8 +1032,8 @@ impl BlocklistAIActionModel {
.get(&conversation_id)
.map(|q| q.len())
.unwrap_or(0);
log::info!(
"[tool-debug] try_to_execute_available_actions: conversation={:?}, pending_count={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: conversation={:?}, pending_count={}",
conversation_id,
pending_count
);
@@ -1000,14 +1044,14 @@ impl BlocklistAIActionModel {
.and_then(|queue| queue.front())
.cloned()
else {
log::info!(
"[tool-debug] try_to_execute_available_actions: no more pending actions"
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: no more pending actions"
);
return;
};
log::info!(
"[tool-debug] try_to_execute_available_actions: trying action id={:?}, type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: trying action id={:?}, type={:?}",
front_action.id,
std::mem::discriminant(&front_action.action)
);
@@ -1019,8 +1063,8 @@ impl BlocklistAIActionModel {
current_phase,
ctx,
) {
log::info!(
"[tool-debug] try_to_execute_available_actions: cannot start in current phase {:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: cannot start in current phase {:?}",
current_phase
);
return;
@@ -1033,12 +1077,12 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::Automatic,
ctx,
) else {
log::info!("[tool-debug] try_to_execute_available_actions: start_pending_action_by_id returned None (blocked)");
crate::ai::tool_diagnostics::tool_debug!("try_to_execute_available_actions: start_pending_action_by_id returned None (blocked)");
return;
};
log::info!(
"[tool-debug] try_to_execute_available_actions: action started, result={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: action started, result={:?}",
std::mem::discriminant(&result)
);
@@ -1048,7 +1092,9 @@ impl BlocklistAIActionModel {
phase: RunningActionPhase::Serial
}
) {
log::info!("[tool-debug] try_to_execute_available_actions: serial async action, stopping loop");
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_available_actions: serial async action, stopping loop"
);
return;
}
}
@@ -1081,14 +1127,6 @@ impl BlocklistAIActionModel {
}
}
/// Returns all pending actions for all conversations.
pub fn get_pending_actions(&self) -> Vec<&AIAgentAction> {
self.pending_actions
.values()
.flat_map(|queue| queue.iter())
.collect()
}
/// Returns all pending actions for a specific conversation.
pub fn get_pending_actions_for_conversation(
&self,
@@ -1106,11 +1144,15 @@ impl BlocklistAIActionModel {
self.blocked_action_for_conversation(&conversation_id)
}
/// Returns a pending action by its ID, searching across all conversations.
pub fn get_pending_action_by_id(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> {
/// Returns a pending action by its ID within the given conversation.
pub fn get_pending_action_by_id(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.pending_actions
.values()
.flat_map(|queue| queue.iter())
.get(&conversation_id)?
.iter()
.find(|action| &action.id == action_id)
}
@@ -1142,7 +1184,11 @@ impl BlocklistAIActionModel {
self.running_actions
.get(&conversation_id)
.and_then(RunningActions::first_action_id)
.and_then(|action_id| self.executor.as_ref(app).async_executing_action(action_id))
.and_then(|action_id| {
self.executor
.as_ref(app)
.async_executing_action(conversation_id, action_id)
})
}
/// Returns whether there is a pending or running action for the active conversation.
@@ -1194,53 +1240,58 @@ impl BlocklistAIActionModel {
self.finished_action_results.get(&conversation_id)
}
/// Returns the `AIActionStatus` for the action corresponding to the given `id`, if any.
pub fn get_action_status(&self, id: &AIAgentActionId) -> Option<AIActionStatus> {
for (conversation_id, pending_actions_for_conversation) in &self.pending_actions {
for (index, action) in pending_actions_for_conversation.iter().enumerate() {
if &action.id != id {
continue;
}
if index == 0
&& !self.is_view_only
&& !self.running_actions.contains_key(conversation_id)
{
return Some(AIActionStatus::Blocked);
}
return Some(AIActionStatus::Queued);
}
/// Returns the status for an action within the given conversation.
pub fn get_action_status(
&self,
conversation_id: AIConversationId,
id: &AIAgentActionId,
) -> Option<AIActionStatus> {
if let Some(status) = pending_action_status(
&self.pending_actions,
&self.running_actions,
conversation_id,
id,
self.is_view_only,
) {
return Some(status);
}
self.running_actions
.values()
.find(|running| running.contains(id))
.get(&conversation_id)
.filter(|running| running.contains(id))
.map(|_| AIActionStatus::RunningAsync)
.or_else(|| {
self.get_action_result(id)
self.get_action_result(conversation_id, id)
.map(|result| AIActionStatus::Finished(result.clone()))
})
.or_else(|| {
self.pending_preprocessed_actions
.values()
.any(|preprocessing| preprocessing.contains(id))
.get(&conversation_id)
.is_some_and(|preprocessing| preprocessing.contains(id))
.then_some(AIActionStatus::Preprocessing)
})
}
pub fn get_action_result(&self, id: &AIAgentActionId) -> Option<&Arc<AIAgentActionResult>> {
// Search through all conversations' finished action results
self.finished_action_results
.values()
.chain(self.provider_finished_action_results.values())
.flat_map(|results| results.iter())
.find(|result| &result.id == id)
.or_else(|| self.past_action_results.get(id))
pub fn get_action_result(
&self,
conversation_id: AIConversationId,
id: &AIAgentActionId,
) -> Option<&Arc<AIAgentActionResult>> {
action_result_for_conversation(
&self.finished_action_results,
&self.provider_finished_action_results,
&self.past_action_results,
conversation_id,
id,
)
}
/// Bulk restore action results from a list of exchanges (used when loading conversations from tasks)
pub fn restore_action_results_from_exchanges(&mut self, exchanges: Vec<&AIAgentExchange>) {
pub fn restore_action_results_from_exchanges(
&mut self,
conversation_id: AIConversationId,
exchanges: Vec<&AIAgentExchange>,
) {
for exchange in exchanges.iter() {
for input in &exchange.input {
if let AIAgentInput::ActionResult { result, .. } = input {
@@ -1257,7 +1308,7 @@ impl BlocklistAIActionModel {
);
}
self.past_action_results
.insert(result_id, Arc::new(result_to_insert));
.insert((conversation_id, result_id), Arc::new(result_to_insert));
}
}
}
@@ -1267,18 +1318,16 @@ impl BlocklistAIActionModel {
/// from the confirmation card.
pub fn execute_run_agents(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
request: ai::agent::action::RunAgentsRequest,
ctx: &mut ModelContext<Self>,
) {
let mut found = None;
for (conv_id, queue) in self.pending_actions.iter_mut() {
if let Some(action) = queue.iter_mut().find(|action| &action.id == action_id) {
found = Some((*conv_id, action));
break;
}
}
let Some((conversation_id, action)) = found else {
let Some(action) = self
.pending_actions
.get_mut(&conversation_id)
.and_then(|queue| queue.iter_mut().find(|action| &action.id == action_id))
else {
log::warn!(
"BlocklistAIActionModel::execute_run_agents: no pending action for {action_id:?}"
);
@@ -1299,20 +1348,19 @@ impl BlocklistAIActionModel {
/// the time the action becomes blocked on user confirmation.
pub fn deny_run_agents(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
reason: String,
ctx: &mut ModelContext<Self>,
) {
let mut found: Option<(AIConversationId, AIAgentAction)> = None;
for (conv_id, queue) in self.pending_actions.iter_mut() {
if let Some(idx) = queue.iter().position(|a| &a.id == action_id) {
if let Some(action) = queue.remove(idx) {
found = Some((*conv_id, action));
}
break;
}
}
let Some((conversation_id, action)) = found else {
let Some(action) = self
.pending_actions
.get_mut(&conversation_id)
.and_then(|queue| {
let index = queue.iter().position(|action| &action.id == action_id)?;
queue.remove(index)
})
else {
log::warn!(
"BlocklistAIActionModel::deny_run_agents: no pending action for {action_id:?}"
);
@@ -1451,6 +1499,7 @@ impl BlocklistAIActionModel {
let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: action.id.clone(),
conversation_id,
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
@@ -1698,14 +1747,14 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
log::info!(
"[tool-debug] queue_actions: queuing {} actions for conversation {:?}",
crate::ai::tool_diagnostics::tool_debug!(
"queue_actions: queuing {} actions for conversation {:?}",
actions.len(),
conversation_id
);
for (i, action) in actions.iter().enumerate() {
log::info!(
"[tool-debug] queue_actions: [{}] id={:?}, type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"queue_actions: [{}] id={:?}, type={:?}",
i,
action.id,
std::mem::discriminant(&action.action)
@@ -1856,7 +1905,7 @@ impl BlocklistAIActionModel {
reason: CancellationReason,
ctx: &mut ModelContext<Self>,
) {
let status = self.get_action_status(action_id);
let status = self.get_action_status(conversation_id, action_id);
let permission_denied = is_permission_denial(reason, status.as_ref());
if self
.running_actions
@@ -1864,7 +1913,7 @@ impl BlocklistAIActionModel {
.is_some_and(|running| running.contains(action_id))
{
self.executor.update(ctx, |executor, ctx| {
executor.cancel_running_async_action(action_id, Some(reason), ctx)
executor.cancel_running_async_action(conversation_id, action_id, Some(reason), ctx)
});
} else {
let Some(pending_actions_for_conversation) =
@@ -1933,12 +1982,14 @@ impl BlocklistAIActionModel {
};
for action in actions_to_cancel.drain(..).collect_vec() {
log::info!(
"Canceling pending action of type {:?} conversation_id={conversation_id:?} action_id={:?}, reason={:?}, backtrace=\n{}",
"Canceling pending action of type {:?} conversation_id={conversation_id:?} action_id={:?}, reason={:?}",
AIAgentActionTypeDiscriminants::from(&action.action),
action.id,
reason,
std::backtrace::Backtrace::force_capture()
reason
);
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Pending action cancellation backtrace:\n{backtrace}");
}
self.cancel_pending_action(conversation_id, action, reason, false, ctx);
}
}
@@ -2053,7 +2104,7 @@ impl BlocklistAIActionModel {
for result in finished_action_results.iter() {
self.past_action_results
.insert(result.id.clone(), result.clone());
.insert((conversation_id, result.id.clone()), result.clone());
}
finished_action_results
.into_iter()
@@ -2091,7 +2142,8 @@ impl BlocklistAIActionModel {
.remove(&(conversation_id, work_id.clone()))
.unwrap_or_default();
for result in results {
self.past_action_results.insert(result.id.clone(), result);
self.past_action_results
.insert((conversation_id, result.id.clone()), result);
}
}
@@ -2123,33 +2175,28 @@ impl BlocklistAIActionModel {
/// respective functions.
pub fn handle_requested_command_accepted(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
command: String,
ctx: &mut ModelContext<Self>,
) {
// Search through all pending conversations to find the action and conversation ID
let mut found_conversation_id = None;
for (conversation_id, pending_actions_for_conversation) in self.pending_actions.iter_mut() {
if let Some(action) = pending_actions_for_conversation
.iter_mut()
.find(|action| action.id == *action_id)
{
if let AIAgentActionType::RequestCommandOutput {
command: original_command,
..
} = &mut action.action
{
*original_command = command;
found_conversation_id = Some(*conversation_id);
break;
}
}
}
let Some(conversation_id) = found_conversation_id else {
let Some(action) = self
.pending_actions
.get_mut(&conversation_id)
.and_then(|actions| actions.iter_mut().find(|action| action.id == *action_id))
else {
log::warn!("Ignoring acceptance for non-pending requested command: {action_id:?}");
return;
};
let AIAgentActionType::RequestCommandOutput {
command: original_command,
..
} = &mut action.action
else {
log::warn!("Ignoring acceptance for non-command action: {action_id:?}");
return;
};
*original_command = command;
self.execute_action(action_id, conversation_id, ctx);
}
@@ -2161,8 +2208,8 @@ impl BlocklistAIActionModel {
cancellation_reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
) {
log::info!(
"[tool-debug] handle_action_result: action_id={:?}, result_type={:?}, cancellation={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"handle_action_result: action_id={:?}, result_type={:?}, cancellation={:?}",
action_result.id,
std::mem::discriminant(&action_result.result),
cancellation_reason
@@ -2453,11 +2500,13 @@ pub enum BlocklistAIActionEvent {
/// Emitted when the action with the given ID requires user confirmation to execute.
ActionBlockedOnUserConfirmation {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID begins execution.
ExecutingAction {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID has finished.
@@ -2496,6 +2545,25 @@ impl BlocklistAIActionEvent {
BlocklistAIActionEvent::InsertCodeReviewComments { action_id, .. } => action_id,
}
}
pub fn conversation_id(&self) -> Option<AIConversationId> {
match self {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
conversation_id, ..
}
| BlocklistAIActionEvent::ExecutingAction {
conversation_id, ..
}
| BlocklistAIActionEvent::FinishedAction {
conversation_id, ..
} => Some(*conversation_id),
BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_)
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => None,
}
}
}
impl Entity for BlocklistAIActionModel {
+215 -51
View File
@@ -74,6 +74,7 @@ use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentWaitPolicy,
};
pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor;
@@ -245,9 +246,36 @@ pub(super) enum TryExecuteResult {
#[derive(Clone)]
struct AsyncExecutingAction {
action: AIAgentAction,
/// The conversation this action belongs to so cancellation and follow-up scheduling remain
/// scoped even when several conversations have async actions in flight.
conversation_id: AIConversationId,
}
type AsyncExecutingActionKey = (AIConversationId, AIAgentActionId);
#[derive(Default)]
struct AsyncExecutingActions(
std::collections::HashMap<AsyncExecutingActionKey, AsyncExecutingAction>,
);
impl AsyncExecutingActions {
fn insert(&mut self, conversation_id: AIConversationId, running: AsyncExecutingAction) {
self.0
.insert((conversation_id, running.action.id.clone()), running);
}
fn get(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AsyncExecutingAction> {
self.0.get(&(conversation_id, action_id.clone()))
}
fn remove(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<AsyncExecutingAction> {
self.0.remove(&(conversation_id, action_id.clone()))
}
}
impl AsyncExecutingAction {
@@ -286,10 +314,8 @@ pub struct BlocklistAIActionExecutor {
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID.
/// We track them per action rather than as a single slot so multiple actions from the same
/// parallel phase can complete independently.
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
/// The actions currently executing asynchronously, scoped by conversation and action ID.
async_executing_actions: AsyncExecutingActions,
restored_action_ids: HashSet<AIAgentActionId>,
/// Reference to the terminal model for checking session sharing state.
@@ -390,9 +416,13 @@ impl BlocklistAIActionExecutor {
}
}
pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> {
pub fn async_executing_action(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.async_executing_actions
.get(action_id)
.get(conversation_id, action_id)
.map(|running| &running.action)
}
@@ -408,13 +438,16 @@ impl BlocklistAIActionExecutor {
}
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
self.async_executing_actions.values().any(|running| {
running.conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
self.async_executing_actions
.0
.iter()
.any(|((running_conversation_id, _), running)| {
*running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
}
/// Returns the action_id of any running WaitForEvents action for the
@@ -424,10 +457,9 @@ impl BlocklistAIActionExecutor {
&self,
conversation_id: AIConversationId,
) -> Option<AIAgentActionId> {
self.async_executing_actions
.iter()
.find_map(|(action_id, running)| {
if running.conversation_id == conversation_id
self.async_executing_actions.0.iter().find_map(
|((running_conversation_id, action_id), running)| {
if *running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::WaitForEvents { .. }
@@ -437,7 +469,8 @@ impl BlocklistAIActionExecutor {
} else {
None
}
})
},
)
}
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
@@ -642,8 +675,8 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool,
ctx: &mut ModelContext<Self>,
) -> TryExecuteResult {
log::info!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id,
std::mem::discriminant(&action.action),
is_user_initiated
@@ -651,7 +684,9 @@ impl BlocklistAIActionExecutor {
// We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() {
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode");
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: BLOCKED - shared session viewer mode"
);
return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action),
@@ -664,8 +699,8 @@ impl BlocklistAIActionExecutor {
};
let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute,
is_agent_autonomous
);
@@ -677,8 +712,8 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation {
log::info!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id
);
return TryExecuteResult::NotExecuted {
@@ -713,8 +748,8 @@ impl BlocklistAIActionExecutor {
}
}
log::info!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id,
std::mem::discriminant(&action.action)
);
@@ -870,8 +905,8 @@ impl BlocklistAIActionExecutor {
};
let action_id = action_clone.id.clone();
log::info!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution {
AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction",
@@ -882,8 +917,8 @@ impl BlocklistAIActionExecutor {
);
match execution {
AnyActionExecution::NotReady => {
log::info!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NOT READY - action_id={:?}",
action_id
);
TryExecuteResult::NotExecuted {
@@ -893,7 +928,7 @@ impl BlocklistAIActionExecutor {
}
AnyActionExecution::InvalidAction => {
log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
"try_to_execute_action: invalid action, action_id={:?}",
action_id
);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
@@ -907,10 +942,9 @@ impl BlocklistAIActionExecutor {
on_complete,
} => {
self.async_executing_actions.insert(
action_id.clone(),
conversation_id,
AsyncExecutingAction {
action: action_clone,
conversation_id,
},
);
if !is_restored {
@@ -919,15 +953,21 @@ impl BlocklistAIActionExecutor {
conversation_id,
});
}
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: spawning ASYNC execution for action_id={:?}",
action_id
);
ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else {
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id);
let Some(running) = me
.async_executing_actions
.remove(conversation_id, &action_id)
else {
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
return;
};
let result = on_complete(result, ctx);
log::info!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id,
std::mem::discriminant(&result)
);
@@ -937,7 +977,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result,
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: None,
});
});
@@ -981,6 +1021,7 @@ impl BlocklistAIActionExecutor {
pub fn cancel_running_async_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
@@ -989,13 +1030,42 @@ impl BlocklistAIActionExecutor {
if self.is_shared_session_viewer() {
return;
}
if let Some(running) = self.async_executing_actions.remove(action_id) {
if self
.async_executing_actions
.get(conversation_id, action_id)
.is_some_and(|running| {
matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
})
{
let termination_requested = self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(action_id, ctx)
});
if termination_requested {
// Keep the action in flight until block completion proves the process stopped.
// Its normal async completion will report the actual terminal exit status.
return;
}
}
if let Some(running) = self
.async_executing_actions
.remove(conversation_id, action_id)
{
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
);
if running.is_shell_command_action() {
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Running action cancellation backtrace:\n{backtrace}");
}
if running.is_shell_command_action()
&& !matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
{
self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
@@ -1007,6 +1077,10 @@ impl BlocklistAIActionExecutor {
self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
} else if matches!(running.action.action, AIAgentActionType::StartAgent { .. }) {
self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_execution(&running.action.id);
});
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action
{
@@ -1023,7 +1097,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result: running.action.action.cancelled_result(),
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: reason,
});
}
@@ -1037,13 +1111,14 @@ impl BlocklistAIActionExecutor {
) {
let action_ids = self
.async_executing_actions
.0
.iter()
.filter_map(|(action_id, running)| {
(running.conversation_id == conversation_id).then_some(action_id.clone())
.filter_map(|((running_conversation_id, action_id), _)| {
(*running_conversation_id == conversation_id).then_some(action_id.clone())
})
.collect::<Vec<_>>();
for action_id in action_ids {
self.cancel_running_async_action(&action_id, reason, ctx);
self.cancel_running_async_action(conversation_id, &action_id, reason, ctx);
}
}
@@ -1493,6 +1568,95 @@ async fn read_file_as_binary(file_path: &std::path::Path) -> Result<Vec<u8>, Fil
async_fs::read(file_path).await.map_err(FileLoadError::from)
}
#[cfg(test)]
mod async_executing_action_tests {
use super::*;
use crate::ai::agent::task::TaskId;
fn action(id: &str, task_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_owned()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new(task_id.to_owned()),
requires_result: true,
tool_name: Some("init_project".to_owned()),
}
}
#[test]
fn duplicate_action_ids_can_execute_concurrently_in_different_conversations() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
assert_eq!(running.0.len(), 2);
assert_eq!(
running
.get(first_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("first-task".to_owned())
);
assert_eq!(
running
.get(second_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("second-task".to_owned())
);
}
#[test]
fn duplicate_action_completion_and_cancellation_remove_only_the_matching_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
let completed = running.remove(first_conversation, &duplicate_id).unwrap();
assert_eq!(
completed.action.task_id,
TaskId::new("first-task".to_owned())
);
assert!(running.get(second_conversation, &duplicate_id).is_some());
let cancelled = running.remove(second_conversation, &duplicate_id).unwrap();
assert_eq!(
cancelled.action.task_id,
TaskId::new("second-task".to_owned())
);
assert!(running.0.is_empty());
}
}
#[cfg(all(test, feature = "local_fs"))]
#[path = "execute_tests.rs"]
mod tests;
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))]
{
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
..
} = input.action
else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
log::error!("CallMCPToolExecutor::execute: action type mismatch");
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
log::info!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name,
server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
);
let serde_json::Value::Object(mut arguments) = input.clone() else {
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
log::error!("CallMCPToolExecutor: input is not an object");
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
let Some(reconnecting_peer) = templatable_peer else {
log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
"CallMCPToolExecutor: MCP server for tool '{}' not found",
name_owned
);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned
);
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
tool_name: String,
ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType {
log::info!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
crate::ai::tool_diagnostics::tool_debug!(
"handle_call_tool_result: tool_name={}, is_ok={}",
tool_name,
res.is_ok()
);
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns,
path
);
@@ -237,8 +237,8 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"GrepExecutor::execute: queries={:?}, path={:?}",
queries,
path
);
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
crate::ai::tool_diagnostics::tool_debug!(
"ReadFilesExecutor::execute: {} files requested",
locations.len()
);
@@ -173,14 +173,14 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor::execute: action_id={:?}",
id
);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor: no diff view found for action_id={:?}",
id
);
return ActionExecution::NotReady;
@@ -21,7 +21,7 @@ use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
StartAgentWaitPolicy,
StartAgentRequestId, StartAgentWaitPolicy,
};
use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
@@ -146,9 +146,12 @@ impl RunAgentsExecutor {
ctx: &mut ModelContext<Self>,
) {
self.recovery_action_ids.remove(action_id);
self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_dispatches_for_action(action_id);
let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_dispatches_for_action(action_id)
});
log::info!(
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
);
if self.pending.remove(action_id).is_some() {
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
action_id: action_id.clone(),
@@ -163,6 +166,22 @@ impl RunAgentsExecutor {
) {
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
self.launched_agents
.entry(conversation_id)
.or_default()
.insert(
normalized_name,
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
},
);
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -372,6 +391,10 @@ impl RunAgentsExecutor {
);
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
let wait_policy = match &run_execution_mode {
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
};
for cfg in &agent_run_configs {
let normalized_name = normalize_agent_name(&cfg.name)
.expect("validated RunAgents requests have non-empty agent names");
@@ -382,7 +405,7 @@ impl RunAgentsExecutor {
cfg.name.clone(),
parent_conversation_id,
child_conversation_id,
parent_run_id.clone(),
wait_policy,
exec_ctx,
)
});
@@ -485,9 +508,9 @@ impl RunAgentsExecutor {
ctx.spawn(
async move {
let outcomes = join_all(slots.into_iter().map(resolve_child_slot)).await;
let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
#[cfg(not(target_family = "wasm"))]
for (slot_index, kind) in outcomes.iter().enumerate() {
for (slot_index, resolved) in resolved_slots.iter().enumerate() {
log::info!(
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
agent_name={} slot_index={} outcome={}",
@@ -498,21 +521,32 @@ impl RunAgentsExecutor {
.map(String::as_str)
.unwrap_or("<unknown>"),
slot_index,
run_agents_agent_outcome_kind_label(kind)
run_agents_agent_outcome_kind_label(&resolved.outcome)
);
}
outcomes
resolved_slots
},
move |me, outcomes, ctx| {
move |me, resolved_slots, ctx| {
if !me.is_pending(&action_id_for_aggr) {
return;
}
let timed_out_request_ids = resolved_slots
.iter()
.filter_map(|resolved| resolved.timed_out_request_id)
.collect::<Vec<_>>();
if !timed_out_request_ids.is_empty() {
me.start_agent_executor.update(ctx, |executor, _| {
for request_id in timed_out_request_ids {
executor.detach_dispatch(request_id);
}
});
}
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter()
.zip(outcomes)
.map(|(cfg, kind)| RunAgentsAgentOutcome {
.zip(resolved_slots)
.map(|(cfg, resolved)| RunAgentsAgentOutcome {
name: cfg.name.clone(),
kind,
kind: resolved.outcome,
})
.collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents);
@@ -526,7 +560,7 @@ impl RunAgentsExecutor {
"action_id": action_id_for_aggr.to_string(),
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
"agent_count": agents.len(),
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. })).count(),
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
"agents": agents
.iter()
@@ -536,6 +570,12 @@ impl RunAgentsExecutor {
"status": "launched",
"agent_id": agent_id.as_str(),
}),
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
"name": agent.name.as_str(),
"status": "completed",
"agent_id": agent_id.as_str(),
"output": output,
}),
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name.as_str(),
"status": "failed",
@@ -723,6 +763,7 @@ fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
match kind {
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
}
}
@@ -732,18 +773,30 @@ enum ChildSlot {
Pending(StartAgentDispatch),
}
async fn resolve_child_slot(slot: ChildSlot) -> RunAgentsAgentOutcomeKind {
#[derive(Debug)]
struct ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind,
timed_out_request_id: Option<StartAgentRequestId>,
}
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
}
async fn resolve_child_slot_with_timeout(
slot: ChildSlot,
spawn_timeout: Duration,
) -> RunAgentsAgentOutcomeKind {
) -> ResolvedChildSlot {
let dispatch = match slot {
ChildSlot::Failed(error) => return RunAgentsAgentOutcomeKind::Failed { error },
ChildSlot::Failed(error) => {
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed { error },
timed_out_request_id: None,
};
}
ChildSlot::Pending(dispatch) => dispatch,
};
let request_id = dispatch.request_id;
let outcome = match dispatch.wait_policy {
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
@@ -754,31 +807,41 @@ async fn resolve_child_slot_with_timeout(
{
futures::future::Either::Left((outcome, _)) => outcome.ok(),
futures::future::Either::Right((_, _)) => {
dispatch.mark_detached();
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()
),
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
spawn_timeout.as_secs()
),
},
timed_out_request_id: Some(request_id),
};
}
}
}
};
match outcome {
Some(StartAgentOutcome::Started { agent_id })
| Some(StartAgentOutcome::Completed { agent_id, .. }) => {
let outcome = match outcome {
Some(StartAgentOutcome::Started { agent_id }) => {
RunAgentsAgentOutcomeKind::Launched { agent_id }
}
Some(StartAgentOutcome::Completed { agent_id, output }) => {
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
}
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
None => RunAgentsAgentOutcomeKind::Failed {
error: "Child agent was cancelled before completion".to_string(),
},
};
ResolvedChildSlot {
outcome,
timed_out_request_id: None,
}
}
@@ -967,6 +1030,19 @@ fn existing_launched_agents_for_conversation(
};
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
existing_agents.entry(normalized_name).or_insert_with(|| {
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
}
});
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{
@@ -525,12 +527,16 @@ fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
let (second_sender, second_receiver) = async_channel::bounded(1);
let slots = vec![
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver: first_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(2),
receiver: second_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Failed("prelaunch failure".to_string()),
];
@@ -557,15 +563,16 @@ fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
let outcomes = outcomes.await;
assert!(matches!(
&outcomes[0],
&outcomes[0].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
));
assert!(matches!(
&outcomes[1],
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "second-agent"
&outcomes[1].outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "second-agent" && output == "done"
));
assert!(matches!(
&outcomes[2],
&outcomes[2].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
));
});
@@ -577,8 +584,10 @@ fn completion_wait_ignores_spawn_timeout() {
let (sender, receiver) = async_channel::bounded(1);
let completion = Box::pin(resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
));
@@ -598,8 +607,9 @@ fn completion_wait_ignores_spawn_timeout() {
.unwrap();
assert!(matches!(
completion.await,
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "child-agent"
completion.await.outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "child-agent" && output == "done"
));
});
}
@@ -610,21 +620,73 @@ fn startup_wait_retains_spawn_timeout() {
let (_sender, receiver) = async_channel::bounded(1);
let outcome = resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Startup,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
)
.await;
assert!(outcome.timed_out_request_id.is_some());
assert_eq!(
outcome.timed_out_request_id,
Some(StartAgentRequestId::from_raw_for_test(1))
);
assert!(matches!(
outcome,
outcome.outcome,
RunAgentsAgentOutcomeKind::Failed { error }
if error.contains("Agent failed to start within")
));
});
}
#[test]
fn startup_timeout_detaches_exact_pending_request() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let start_agent_executor = state.start_agent_executor;
let parent_conversation_id = state.conversation_id;
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-timeout".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some("parent-run".to_string()),
ctx,
)
});
let request_id = dispatch.request_id;
let resolved =
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
.await;
let timed_out_request_id = resolved
.timed_out_request_id
.expect("startup timeout should expose request identity");
start_agent_executor.update(&mut app, |executor, _| {
assert!(executor.detach_dispatch(timed_out_request_id));
});
start_agent_executor.read(&app, |executor, _| {
assert!(!executor.has_pending_dispatch_for_test(request_id));
});
});
}
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);
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_util::path::ShellFamily;
use galaxyui::r#async::{Spawnable, Timer};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
event: &ModelEvent,
_ctx: &mut ModelContext<Self>,
) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
// evidence for shells that never deliver a subsequent precmd.
if matches!(
event,
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
| ModelEvent::BlockCompleted(_)
) {
let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
if let Some(block) = block_selector.get_block(&model) {
if block.is_command_finished() {
let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
for (block_selector, block_finished_txs) in block_finished_senders {
let completed_block = block_selector.get_block(&model).filter(|block| {
block.is_command_finished()
&& match event {
ModelEvent::BlockCompleted(completed) => {
block.id() == &completed.block_id
}
ModelEvent::BlockMetadataReceived(_) => true,
_ => false,
}
});
if completed_block.is_some() {
for block_finished_tx in block_finished_txs {
if let Err(e) = block_finished_tx.send(()) {
log::warn!(
"Failed to notify block completion for running requested command: {e:?}"
)
}
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
}
} else {
// The requested-command association may not exist yet. Keep all waiters until
// this selector resolves and its block actually completes, or it is cancelled.
self.block_finished_senders
.insert(block_selector, block_finished_txs);
}
}
}
@@ -195,8 +209,8 @@ impl ShellCommandExecutor {
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
log::info!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action)
);
let model = self.terminal_model.lock();
@@ -204,12 +218,6 @@ impl ShellCommandExecutor {
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle();
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
@@ -222,18 +230,13 @@ impl ShellCommandExecutor {
.active_block()
.is_active_and_long_running()
{
// Another command is still running (e.g. stuck in a pager). Return an error
// result so the model receives feedback and can adapt. Using Completed with a
// non-zero exit code ensures a follow-up request is triggered.
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed {
command: command.clone(),
block_id: model.block_list().active_block().id().clone(),
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
exit_code: ExitCode::from(1),
start_ts: None,
completed_ts: None,
},
let running_command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false);
return ActionExecution::Sync(terminal_busy_execution_error(
command,
&running_command,
));
}
// If another conversation has taken over the agent view since this command
@@ -275,8 +278,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -339,8 +341,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -371,6 +372,7 @@ impl ShellCommandExecutor {
},
));
}
let command = block.command_with_secrets_unobfuscated(false);
drop(model);
let block_selector = BlockSelector::Id(block_id.clone());
@@ -380,8 +382,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -419,7 +420,9 @@ impl ShellCommandExecutor {
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx);
.entry(block_selector.clone())
.or_default()
.push(block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
@@ -491,7 +494,7 @@ impl ShellCommandExecutor {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
me.control_handback_sender = None;
});
}
@@ -520,13 +523,17 @@ impl ShellCommandExecutor {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
.entry(block_selector.clone())
.or_default()
.push(block_metadata_received_tx);
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
.entry(block_selector.clone())
.or_default()
.push(force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
@@ -600,7 +607,12 @@ impl ShellCommandExecutor {
completed_ts: block.completed_ts().cloned(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
model.is_alt_screen_active(),
model.active_block_id(),
block.id(),
);
let grid_contents = if selected_block_owns_alt_screen {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
@@ -618,7 +630,7 @@ impl ShellCommandExecutor {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted,
}
}
@@ -630,23 +642,50 @@ impl ShellCommandExecutor {
}
}
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
pub(super) fn cancel_execution(
&mut self,
id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> bool {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return;
}
let selector = if active_block
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
let requested_selector = BlockSelector::RequestedCommandId(id.clone());
let requested_block_is_running = requested_selector
.get_block(&terminal_model)
.is_some_and(|block| block.is_active_and_long_running() && !block.finished());
let selector = if requested_block_is_running {
requested_selector
} else {
BlockSelector::Id(active_block.id().clone())
BlockSelector::Id(terminal_model.active_block_id().clone())
};
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
// Cancelling the wait future alone would report cancellation while the process keeps
// running. Terminate the exact requested command before resolving the action as cancelled.
if requested_block_is_running {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
}
if !requested_block_is_running {
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
requested_block_is_running
}
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
}
fn prune_closed_sender_group(
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
selector: &BlockSelector,
) {
if let Some(selector_senders) = senders.get_mut(selector) {
selector_senders.retain(|sender| !sender.is_canceled());
if selector_senders.is_empty() {
senders.remove(selector);
}
}
}
/// Force any in-flight poll for the given long-running command block to resolve
@@ -657,9 +696,8 @@ impl ShellCommandExecutor {
/// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
// at a time.
// Find every pending poll whose selector resolves to this block. Multiple provider polls
// may legitimately wait on the same command and must be refreshed together.
let matching_selector = self
.force_refresh_senders
.keys()
@@ -674,8 +712,12 @@ impl ShellCommandExecutor {
drop(terminal_model);
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
return sender.send(()).is_ok();
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
let mut refreshed = false;
for sender in senders {
refreshed |= sender.send(()).is_ok();
}
return refreshed;
}
}
false
@@ -714,6 +756,21 @@ fn command_for_execution(
}
}
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
command: command.to_string(),
message: format!("terminal is busy running command '{running_command}'"),
})
}
fn selected_block_owns_alt_screen(
is_alt_screen_active: bool,
active_block_id: &BlockId,
selected_block_id: &BlockId,
) -> bool {
is_alt_screen_active && active_block_id == selected_block_id
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector {
Id(BlockId),
@@ -919,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
input: Bytes,
mode: AIAgentPtyWriteMode,
},
CancelExecution,
CancelExecution {
action_id: AIAgentActionId,
},
/// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser {
action_id: AIAgentActionId,
@@ -1,19 +1,29 @@
use std::sync::Arc;
use std::task::Poll;
use async_channel::unbounded;
use futures::channel::oneshot;
use futures::{pin_mut, poll};
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{command_for_execution, ActionResult, BlockSelector, ShellCommandExecutor};
use crate::ai::agent::ShellCommandDelay;
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
use super::{
command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
ActionResult, BlockSelector, ShellCommandExecutor,
};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
};
use crate::terminal::event::{
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::AIConversationId;
#[test]
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
@@ -31,6 +41,41 @@ fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
);
}
#[test]
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
let result = terminal_busy_execution_error("cargo test", "sleep 120");
assert!(matches!(
result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { command, message }
) if command == "cargo test"
&& message == "terminal is busy running command 'sleep 120'"
));
}
#[test]
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
let active_block_id = BlockId::new();
let selected_block_id = BlockId::new();
assert!(!selected_block_owns_alt_screen(
true,
&active_block_id,
&selected_block_id
));
assert!(selected_block_owns_alt_screen(
true,
&active_block_id,
&active_block_id
));
assert!(!selected_block_owns_alt_screen(
false,
&active_block_id,
&active_block_id
));
}
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on
@@ -63,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
let selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx);
executor.block_finished_senders.insert(selector, vec![tx]);
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
@@ -88,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
that map is reserved for precmd (BlockMetadataReceived)"
);
// Precmd event — the senders map should be drained (and since the
// block isn't in the terminal model, the sender is dropped).
// An unrelated precmd cannot resolve this selector, so its waiter must survive.
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
@@ -102,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0,
"BlockMetadataReceived should drain the finish senders"
1,
"BlockMetadataReceived must retain unresolved finish senders"
);
});
}
@@ -138,7 +182,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
assert!(executor.force_refresh_block(&block_id));
assert!(!executor.force_refresh_block(&block_id));
});
@@ -149,7 +193,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
@@ -158,6 +202,150 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
});
}
#[test]
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let action_id = AIAgentActionId::from("requested-command".to_string());
let result_future = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::RequestedCommandId(action_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(result_future);
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert!(matches!(poll!(&mut result_future), Poll::Pending));
terminal_model
.lock()
.simulate_long_running_block("printf done", "done");
let block_id = terminal_model.lock().active_block_id().clone();
terminal_model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_requested_command(
action_id,
None,
AIConversationId::new(),
);
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
assert!(matches!(
result_future.await,
ActionResult::CommandFinished {
block_id: result_block_id,
..
} if result_block_id == block_id
));
});
}
#[test]
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 1", "finished");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let first = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
let second = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(first);
pin_mut!(second);
assert!(matches!(poll!(&mut first), Poll::Pending));
assert!(matches!(poll!(&mut second), Poll::Pending));
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
let first_result = first.await;
let second_result = second.await;
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
assert!(matches!(
second_result,
ActionResult::CommandFinished { .. }
));
});
}
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
BlockCompletedEvent {
block_latency_data: None,
block_type: BlockType::Restored,
num_secrets_obfuscated: 0,
block_index: BlockIndex::zero(),
block_id,
session_id: None,
restored_block_was_local: None,
}
}
#[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures::future::BoxFuture;
use futures::FutureExt;
@@ -42,9 +44,24 @@ pub enum StartAgentWaitPolicy {
Completion,
}
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
match mode {
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
}
}
pub struct StartAgentDispatch {
pub request_id: StartAgentRequestId,
pub receiver: async_channel::Receiver<StartAgentOutcome>,
pub wait_policy: StartAgentWaitPolicy,
pub(super) detached: Arc<AtomicBool>,
}
impl StartAgentDispatch {
pub(super) fn mark_detached(&self) {
self.detached.store(true, Ordering::Release);
}
}
fn invalid_local_child_harness_error(harness_type: &str) -> String {
@@ -139,6 +156,7 @@ struct PendingStartAgent {
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>,
detached: Arc<AtomicBool>,
/// 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.
@@ -176,9 +194,17 @@ impl StartAgentExecutor {
ctx: &mut ModelContext<Self>,
) {
let child_link_event = {
let Some(pending) = self.pending.get_mut(&request_id) else {
let Some(pending) = self.pending.get(&request_id) else {
return;
};
if pending.detached.load(Ordering::Acquire) {
self.pending.remove(&request_id);
return;
}
let pending = self
.pending
.get_mut(&request_id)
.expect("pending request was checked above");
pending.child_conversation_id = Some(child_conversation_id);
if let Some(agent_name) = pending.run_agents_child_name.clone() {
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
@@ -595,16 +621,13 @@ 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_policy = if parent_run_id.is_none() {
StartAgentWaitPolicy::Completion
} else {
StartAgentWaitPolicy::Startup
};
// Local children return their completed work; remote children acknowledge startup and
// continue through the hosted orchestration lifecycle.
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
@@ -613,6 +636,7 @@ impl StartAgentExecutor {
parent_conversation_id,
child_conversation_id: None,
sender,
detached,
wait_policy,
},
);
@@ -667,24 +691,23 @@ impl StartAgentExecutor {
parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch {
let wait_policy = if parent_run_id.is_none() {
StartAgentWaitPolicy::Completion
} else {
StartAgentWaitPolicy::Startup
};
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error));
return StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
};
}
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let prompt = compose_leaf_agent_prompt(&prompt);
let request_id = self.next_request_id();
self.pending.insert(
request_id,
PendingStartAgent {
@@ -693,6 +716,7 @@ impl StartAgentExecutor {
parent_conversation_id,
child_conversation_id: None,
sender,
detached: detached.clone(),
wait_policy,
},
);
@@ -708,8 +732,10 @@ impl StartAgentExecutor {
},
)));
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
@@ -719,16 +745,12 @@ impl StartAgentExecutor {
name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
parent_run_id: Option<String>,
wait_policy: StartAgentWaitPolicy,
ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch {
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();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
@@ -737,19 +759,54 @@ impl StartAgentExecutor {
parent_conversation_id,
child_conversation_id: Some(child_conversation_id),
sender,
detached: detached.clone(),
wait_policy,
},
);
self.record_child_conversation(request_id, child_conversation_id, ctx);
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
pub fn cancel_dispatches_for_action(&mut self, action_id: &AIAgentActionId) {
self.pending
.retain(|_, pending| &pending.action_id != action_id);
/// Detaches one exact dispatch. If its launch callback is already queued,
/// the shared marker prevents that callback from linking a late child.
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
let Some(pending) = self.pending.remove(&request_id) else {
return false;
};
pending.detached.store(true, Ordering::Release);
true
}
/// Test-only lookup for request ownership without exposing executor internals.
#[cfg(test)]
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
self.pending.contains_key(&request_id)
}
pub fn cancel_dispatches_for_action(&mut self, action_id: &AIAgentActionId) -> usize {
let request_ids = self
.pending
.iter()
.filter_map(|(request_id, pending)| {
(&pending.action_id == action_id).then_some(*request_id)
})
.collect::<Vec<_>>();
let detached_count = request_ids.len();
for request_id in request_ids {
self.detach_dispatch(request_id);
}
detached_count
}
/// Cancels only the caller's pending tool wait. A child that was already created keeps
/// running independently and remains available in conversation history.
pub(super) fn cancel_execution(&mut self, action_id: &AIAgentActionId) {
self.cancel_dispatches_for_action(action_id);
}
pub(super) fn preprocess_action(
@@ -257,6 +257,71 @@ fn dispatch_denies_child_conversation_defense_in_depth() {
});
}
#[test]
fn local_execution_waits_for_completion() {
assert_eq!(
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
StartAgentWaitPolicy::Completion
);
}
#[test]
fn detach_dispatch_rejects_late_child_callback() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut 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 parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some(PARENT_RUN_ID.to_string()),
ctx,
)
});
assert!(executor.update(&mut app, |executor, _| {
executor.detach_dispatch(dispatch.request_id)
}));
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
dispatch.request_id,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
assert!(dispatch.receiver.try_recv().is_err());
});
}
#[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
@@ -878,6 +943,69 @@ fn direct_provider_error_preserves_child_for_inspection() {
});
}
#[test]
fn cancelling_standalone_start_agent_drops_dispatch_but_keeps_child_running() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut 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 parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
},
ctx,
)
.into()
});
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
executor.update(&mut app, |executor, _| {
executor.cancel_execution(&action.id);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
history_model.read(&app, |history, _| {
assert_eq!(
history
.conversation(&child_conversation_id)
.expect("child should remain in history")
.status(),
&ConversationStatus::InProgress
);
});
let AnyActionExecution::Async { execute_future, .. } = execution else {
panic!("expected async StartAgent execution");
};
let _ = execute_future.await;
});
}
#[test]
fn removing_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
@@ -975,7 +1103,7 @@ fn reattach_reuses_persisted_child_without_launching_another_agent() {
"child".to_string(),
parent_conversation_id,
child_conversation_id,
None,
StartAgentWaitPolicy::Completion,
ctx,
)
});
+125
View File
@@ -346,3 +346,128 @@ fn only_rejecting_a_blocked_action_is_a_permission_denial() {
Some(&AIActionStatus::Blocked),
));
}
#[test]
fn duplicate_action_ids_resolve_only_within_the_requested_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let first_result = make_action_result("duplicate");
let mut second_result = action_result("duplicate", AIAgentActionResultType::InitProject);
second_result.task_id = TaskId::new("second-task".to_string());
let second_result = Arc::new(second_result);
let finished_results = HashMap::from([(first_conversation, vec![first_result.clone()])]);
let provider_results = HashMap::new();
let archive = HashMap::from([
(
(first_conversation, duplicate_id.clone()),
first_result.clone(),
),
(
(second_conversation, duplicate_id.clone()),
second_result.clone(),
),
]);
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
first_conversation,
&duplicate_id,
)
.unwrap(),
&first_result,
));
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
second_conversation,
&duplicate_id,
)
.unwrap(),
&second_result,
));
assert!(action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
AIConversationId::new(),
&duplicate_id,
)
.is_none());
}
#[test]
fn cancellation_permission_inference_uses_the_matching_conversation_status() {
let blocked_conversation = AIConversationId::new();
let queued_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let pending_actions = HashMap::from([
(blocked_conversation, VecDeque::from([action("duplicate")])),
(
queued_conversation,
VecDeque::from([action("first"), action("duplicate")]),
),
]);
let running_actions = HashMap::new();
let blocked_status = pending_action_status(
&pending_actions,
&running_actions,
blocked_conversation,
&duplicate_id,
false,
);
let queued_status = pending_action_status(
&pending_actions,
&running_actions,
queued_conversation,
&duplicate_id,
false,
);
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
blocked_status.as_ref(),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
queued_status.as_ref(),
));
}
#[test]
fn action_lifecycle_events_disambiguate_duplicate_ids_by_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let events = [
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::ExecutingAction {
action_id: duplicate_id.clone(),
conversation_id: second_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::FinishedAction {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
cancellation_reason: None,
execution_ref: None,
},
];
assert_eq!(events[0].conversation_id(), Some(first_conversation));
assert_eq!(events[1].conversation_id(), Some(second_conversation));
assert_eq!(events[2].conversation_id(), Some(first_conversation));
assert!(events
.iter()
.all(|event| event.action_id() == &duplicate_id));
}
+69 -24
View File
@@ -3578,7 +3578,10 @@ impl AIBlock {
}
// Set the state based on the action status from the action model
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id)
@@ -3673,6 +3676,7 @@ impl AIBlock {
RequestedCommandViewEvent::Accepted => {
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
action_id,
view.as_ref(ctx).command_text().to_string(),
ctx,
@@ -3691,7 +3695,10 @@ impl AIBlock {
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
// We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block).
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = {
let terminal_model = self.terminal_model.lock();
terminal_model
@@ -3890,7 +3897,7 @@ impl AIBlock {
if self
.action_model
.as_ref(ctx)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_blocked())
{
ctx.focus(&view);
@@ -4274,7 +4281,10 @@ impl AIBlock {
// but it's not incorrect to populate if it is, and we rely on this for
// for restored conversations because action model events don't re-fire
// after the view is created.
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
if let Some(view) = self.search_codebase_view.get(action_id) {
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
@@ -4708,7 +4718,11 @@ impl AIBlock {
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
self.requested_action_ids
.iter()
.filter_map(|id| self.action_model.as_ref(app).get_action_status(id))
.filter_map(|id| {
self.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, id)
})
.any(|status| status.is_blocked())
}
@@ -4734,7 +4748,12 @@ impl AIBlock {
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
let action_id = event.action_id();
if me.is_finished() || !me.requested_action_ids.contains(action_id) {
if event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id)
|| me.is_finished()
|| !me.requested_action_ids.contains(action_id)
{
// Technically, this subscription should be unregistered after `is_finished` is
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
// call, so early return here if this is errantly being called.
@@ -4828,7 +4847,7 @@ impl AIBlock {
{
let should_collapse = action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.is_none_or(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { exit_code, .. },
@@ -4843,7 +4862,9 @@ impl AIBlock {
}
if let Some(view) = me.search_codebase_view.get(action_id) {
let new_status = action_model.as_ref(ctx).get_action_status(action_id);
let new_status = action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id);
view.update(ctx, |view, ctx| {
view.update_status(new_status);
ctx.notify();
@@ -4852,7 +4873,9 @@ impl AIBlock {
// Create subagent panel state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) =
action_model.as_ref(ctx).get_action_status(action_id)
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id)
{
if let AIAgentActionResultType::StartAgent(
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
@@ -4874,7 +4897,11 @@ impl AIBlock {
let action_statuses = me
.requested_action_ids
.iter()
.filter_map(|id| action_model.as_ref(ctx).get_action_status(id))
.filter_map(|id| {
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, id)
})
.collect_vec();
// Detecting links on SearchCodebase tool call outputs
@@ -4907,7 +4934,9 @@ impl AIBlock {
view.update_render_read_file_args(
&me.find_state,
files.clone(),
action_model.as_ref(ctx).get_action_status(action_id),
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id),
);
ctx.notify();
})
@@ -4917,7 +4946,9 @@ impl AIBlock {
// Open the AI document pane when documents are created or edited
if let Some(action_result) =
action_model.as_ref(ctx).get_action_result(action_id)
action_model
.as_ref(ctx)
.get_action_result(me.client_ids.conversation_id, action_id)
{
match &action_result.result {
AIAgentActionResultType::CreateDocuments(
@@ -5677,7 +5708,9 @@ impl AIBlock {
/// This hides their keybindings in the UI and makes them less interactive.
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
self.action_model.update(ctx, |action_model, ctx| {
for action in action_model.get_pending_actions() {
for action in
action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
{
if let Some(edit) = self.requested_edits.get(&action.id) {
edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
@@ -5730,7 +5763,12 @@ impl AIBlock {
.view
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(&action_id, command_text, ctx);
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
&action_id,
command_text,
ctx,
);
});
ctx.notify();
}
@@ -5758,12 +5796,11 @@ impl AIBlock {
/// Finds the undismissed passive code diff across all pending actions.
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
let all_pending_actions = self.action_model.as_ref(app).get_pending_actions();
// Find any RequestFileEdits action that has a corresponding passive code diff view.
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
all_pending_actions
.iter()
self.action_model
.as_ref(app)
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
.find_map(|action| match &action.action {
AIAgentActionType::RequestFileEdits {
file_edits: _,
@@ -5803,7 +5840,10 @@ impl AIBlock {
.is_none_or(|output| {
output.get().actions().last().is_none_or(|action| {
let is_streaming = self.model.status(app).is_streaming();
let status = self.action_model.as_ref(app).get_action_status(&action.id);
let status = self
.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, &action.id);
is_streaming || status.is_some_and(|status| status.is_running())
})
})
@@ -5830,7 +5870,7 @@ impl AIBlock {
.any(|(action_id, requested_command)| {
self.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_running())
&& requested_command.view.as_ref(app).is_header_expanded()
})
@@ -5930,7 +5970,10 @@ impl AIBlock {
return String::new();
};
let output = output.get();
output.format_for_copy(Some(self.action_model.as_ref(app)))
output.format_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
)
}
/// Gets AI output text for copying from the preceding user query until the next user query
@@ -5985,8 +6028,10 @@ impl AIBlock {
// Collect all AI outputs from start_idx to end_idx (exclusive)
let mut combined_result = Vec::new();
for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
let formatted_output =
exchange.format_output_for_copy(Some(self.action_model.as_ref(app)));
let formatted_output = exchange.format_output_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
);
if !formatted_output.is_empty() {
combined_result.push(formatted_output);
}
@@ -7158,7 +7203,7 @@ impl TypedActionView for AIBlock {
let Some(result) = self
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(self.client_ids.conversation_id, action_id)
.map(Arc::clone)
else {
continue;
+1 -1
View File
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
let is_cancelled = self
.action_model
.as_ref(app)
.get_action_status(&action.id)
.get_action_status(self.conversation_id, &action.id)
.is_some_and(|status| status.is_cancelled());
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app)
+139 -31
View File
@@ -41,6 +41,7 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
initial_requested_command_conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
@@ -171,9 +172,21 @@ impl CLISubagentController {
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned();
@@ -183,9 +196,21 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction { .. } => {
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
@@ -197,12 +222,13 @@ impl CLISubagentController {
}
BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id,
conversation_id,
..
} => {
let action_result = me
.action_model
.as_ref(ctx)
.get_action_result(finished_action_id);
.get_action_result(*conversation_id, finished_action_id);
let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| {
matches!(
@@ -222,14 +248,22 @@ impl CLISubagentController {
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
if matches_active_requested_command(
*conversation_id,
finished_action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
active_block.update_is_agent_blocked(false);
let active_command_action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
let active_command_action_id =
active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id {
@@ -244,18 +278,17 @@ impl CLISubagentController {
if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref()
!= Some(finished_action_id)
|| !matches_requested_command_identity(
*conversation_id,
finished_action_id,
state.initial_requested_command_conversation_id,
state.initial_requested_command_action_id.as_ref(),
)
});
}
drop(terminal_model);
if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
me.advance_completed_subagent(&block_id, ctx);
}
}
_ => (),
@@ -322,7 +355,7 @@ impl CLISubagentController {
};
drop(terminal_model);
let provider_consumed_completion = completion.as_ref().is_some_and(|completion| {
let provider_accepted_completion = completion.as_ref().is_some_and(|completion| {
me.controller.update(ctx, |controller, ctx| {
controller.accept_provider_command_completion(
completion.conversation_id,
@@ -344,13 +377,9 @@ impl CLISubagentController {
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if provider_consumed_completion {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
if provider_accepted_completion {
// The provider controller owns deactivation after it applies the queued
// completion at a safe run boundary.
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
@@ -628,11 +657,18 @@ impl CLISubagentController {
///
/// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
self.active_subagents_by_block
pub fn track_requested_command(
&mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
let state = self
.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_action_id = Some(action_id.clone());
.or_default();
state.initial_requested_command_conversation_id = Some(conversation_id);
state.initial_requested_command_action_id = Some(action_id.clone());
}
/// Force the currently in-flight poll for the given long-running command block to
@@ -879,6 +915,10 @@ impl CLISubagentController {
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_conversation_id = Some(conversation_id);
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
@@ -1027,6 +1067,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. },
)
| AIAgentActionResultType::WriteToLongRunningShellCommand(
@@ -1085,6 +1126,26 @@ fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent:
!last_exchange_has_action && !monitor_nudge_sent
}
fn matches_active_requested_command(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
active_conversation_id: Option<AIConversationId>,
active_requested_command_id: Option<&AIAgentActionId>,
) -> bool {
active_conversation_id == Some(event_conversation_id)
&& active_requested_command_id == Some(event_action_id)
}
fn matches_requested_command_identity(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
requested_command_conversation_id: Option<AIConversationId>,
requested_command_action_id: Option<&AIAgentActionId>,
) -> bool {
requested_command_conversation_id == Some(event_conversation_id)
&& requested_command_action_id == Some(event_action_id)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1121,4 +1182,51 @@ mod tests {
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
}
+5 -1
View File
@@ -149,7 +149,11 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
let output = output.get();
output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.message {
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) {
if let Some(status) = self.conversation_id(app).and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, &action.id)
}) {
return status.is_blocked().then_some(action.clone());
}
}
+21 -4
View File
@@ -328,10 +328,27 @@ impl BlocklistAIStatusBar {
},
);
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (),
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction {
conversation_id, ..
}
| BlocklistAIActionEvent::FinishedAction {
conversation_id, ..
} if me
.active_exchange_model
.as_ref()
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)) =>
{
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. }
| BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_)
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => {}
});
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => {
+1
View File
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
contents.add_child(output::render(
output::Props {
conversation_id: self.client_ids.conversation_id,
model: self.model.as_ref(),
state_handles: &self.state_handles,
action_buttons: &self.action_buttons,
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let orchestrator_agent_id = props
.model
.conversation(app)
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else {
+68 -22
View File
@@ -56,6 +56,7 @@ use super::{
};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
@@ -143,6 +144,7 @@ fn should_render_requested_edit(action_status: Option<&AIActionStatus>) -> bool
/// Data required to render the AI block output component.
#[derive(Copy, Clone)]
pub(crate) struct Props<'a> {
pub(crate) conversation_id: AIConversationId,
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
pub(super) state_handles: &'a AIBlockStateHandles,
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
@@ -436,7 +438,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -476,7 +478,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
// checks if the read file action result is completed and successful.
@@ -565,8 +567,10 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
id,
..
}) => {
let action_status =
props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
if should_render_requested_edit(action_status.as_ref()) {
if let Some(requested_edit) = props.requested_edits.get(id) {
@@ -656,7 +660,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -1374,7 +1378,13 @@ fn render_runtime_activity(
}
}
Some(render_tool_pane_shell(content.finish(), false, false, app))
Some(render_tool_pane_shell(
content.finish(),
false,
is_expanded,
false,
app,
))
}
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
@@ -1473,7 +1483,10 @@ fn render_search_codebase(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
@@ -1974,7 +1987,10 @@ fn render_read_files(
parsed_skill: Option<&ai::skills::ParsedSkill>,
action_index: usize,
) -> Box<dyn Element> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let formatted_files =
render_read_files_text(props.into(), file_names, app, appearance, action_index);
@@ -2091,7 +2107,10 @@ fn maybe_render_edit_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -2101,7 +2120,7 @@ fn maybe_render_edit_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2128,7 +2147,10 @@ fn maybe_render_create_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -2138,7 +2160,7 @@ fn maybe_render_create_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2441,7 +2463,7 @@ fn render_suggest_new_conversation(
let status = props
.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(props.conversation_id, action_id)
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
@@ -2549,7 +2571,10 @@ fn create_formatted_text_for_grep(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2653,7 +2678,10 @@ fn create_formatted_text_for_file_glob(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2754,7 +2782,10 @@ fn render_file_retrieval_tool(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
@@ -2871,7 +2902,10 @@ fn render_read_mcp_resource(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(name, app);
@@ -2948,11 +2982,14 @@ fn render_upload_artifact(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let result = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.and_then(|result| match &result.result {
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
_ => None,
@@ -3011,7 +3048,7 @@ fn render_use_computer(
let has_screenshot = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.is_some_and(|result| {
matches!(
&result.result,
@@ -3057,7 +3094,10 @@ fn render_request_computer_use(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(&request.task_summary, app);
@@ -3638,7 +3678,13 @@ pub fn action_icon<V: View>(
app: &AppContext,
) -> galaxyui::elements::Icon {
let appearance = Appearance::as_ref(app);
let status = action_model.as_ref(app).get_action_status(action_id);
let status = ai_block_model
.conversation_id(app)
.and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, action_id)
});
match status {
Some(status) => match status {
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
+439 -169
View File
@@ -10,7 +10,7 @@ mod pending_response_streams;
pub mod response_stream;
pub(super) mod shared_session;
mod slash_command;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
use std::sync::Arc;
@@ -23,7 +23,8 @@ use futures::channel::oneshot;
use galaxy_agent_core::{
turn_control, ExternalWorkId, PendingToolBatch, PendingToolCallState, ProviderRun,
ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRunState,
ToolLoopGuard, TurnCommand, TurnCommandSender, TurnRequest,
StopReason, ToolLoopGuard, ToolResult, ToolResultStatus, TurnCommand, TurnCommandSender,
TurnRequest,
};
use galaxy_core::assertions::safe_assert;
use input_context::{input_context_for_request, parse_context_attachments};
@@ -630,6 +631,32 @@ enum ProviderCommandResult {
},
}
fn convert_provider_tool_batch(
action_context: &ProviderActionContext,
batch: &PendingToolBatch,
) -> (Vec<(AIAgentAction, bool)>, Vec<ToolResult>) {
let mut actions = Vec::new();
let mut invalid_results = Vec::new();
for pending in batch
.calls
.iter()
.filter(|pending| pending.state.result().is_none())
{
match action_context.action_from_tool_call(&pending.call) {
Ok(action) => actions.push((
action,
matches!(pending.state, PendingToolCallState::RecoveryPending),
)),
Err(message) => invalid_results.push(ToolResult {
call_id: pending.call.id.clone(),
content: format!("Invalid {} tool input: {message}", pending.call.name),
status: ToolResultStatus::Error,
}),
}
}
(actions, invalid_results)
}
struct ActiveProviderRunSlot {
stream_id: ResponseStreamId,
response_stream: ModelHandle<ResponseStream>,
@@ -650,6 +677,13 @@ struct ActiveProviderRunSlot {
monitor_prose_continuations: usize,
}
struct QueuedProviderRun {
slot: ActiveProviderRunSlot,
base_provider_config: crate::ai::provider::ProviderConfig,
cli_provider_config: crate::ai::provider::ProviderConfig,
request_params: api::RequestParams,
}
#[derive(Clone)]
struct ActiveProviderRunCheckpoint {
run: ProviderRun,
@@ -663,6 +697,7 @@ struct ActiveProviderRunCheckpoint {
struct PreparedRestoredProviderRun {
snapshot: ActiveProviderRunSnapshot,
profiles: BTreeMap<String, ProviderRunProfile>,
projection_was_initialized: bool,
}
impl ActiveProviderRunCheckpoint {
@@ -706,6 +741,8 @@ struct ActiveProviderRunSnapshot {
root_task_id: TaskId,
did_input_contain_user_query: bool,
persistence_offset: usize,
#[serde(default)]
cancellation_reason: Option<CancellationReason>,
committed_provider_batch: Option<ExternalWorkId>,
#[serde(default)]
finished_provider_batch: Option<ExternalWorkId>,
@@ -746,6 +783,7 @@ impl ActiveProviderRunSnapshot {
root_task_id: slot.root_task_id.clone(),
did_input_contain_user_query: slot.did_input_contain_user_query,
persistence_offset: checkpoint.persistence_offset,
cancellation_reason: slot.cancellation_reason,
committed_provider_batch: slot.committed_provider_batch.clone(),
finished_provider_batch: slot.finished_provider_batch.clone(),
command_action_refs: slot.command_action_refs.clone(),
@@ -996,11 +1034,25 @@ fn normalize_restored_provider_snapshot(
fn apply_restored_provider_command_evidence(
conversation_id: AIConversationId,
snapshot: &mut ActiveProviderRunSnapshot,
evidence: RestoredProviderCommandEvidence,
evidence: Option<RestoredProviderCommandEvidence>,
) -> Result<(), String> {
let Some(monitor) = snapshot.command_monitor.as_ref() else {
return Ok(());
};
let Some(evidence) = evidence else {
snapshot.pending_monitor_observation = None;
snapshot.pending_command_completion = Some(PendingProviderCommandCompletion {
block_id: monitor.block_id.clone(),
initial_requested_command_action_id: Some(
monitor.initial_requested_command_action_id.clone(),
),
command: monitor.command.clone(),
output: "The monitored command was interrupted while Galaxy was offline; its terminal block is no longer available."
.to_owned(),
exit_code: 130,
});
return Ok(());
};
if evidence.conversation_id != Some(conversation_id)
|| evidence.requested_command_action_id.as_ref()
!= Some(&monitor.initial_requested_command_action_id)
@@ -1038,6 +1090,20 @@ fn apply_restored_provider_command_evidence(
Ok(())
}
fn restored_projection_was_initialized(
has_output: bool,
has_server_output_id: bool,
has_added_messages: bool,
) -> Result<bool, String> {
match (has_output, has_server_output_id, has_added_messages) {
(false, false, false) => Ok(false),
(true, true, _) => Ok(true),
(false, true, _) | (false, false, true) | (true, false, _) => {
Err("restored provider projection exchange is partially initialized".to_owned())
}
}
}
fn provider_execution_matches_active_work(
run_id: &ProviderRunId,
active_work_id: Option<&ExternalWorkId>,
@@ -1147,6 +1213,7 @@ fn classify_provider_command_result(
command: Some(command.clone()),
}),
RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. } => None,
},
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
@@ -1430,7 +1497,7 @@ fn provider_llm_lifecycle(projection: &ProviderRunProjection) -> Option<Provider
error: Some(error.message.clone()),
},
ProviderRunProjection::ModelEvent { .. } | ProviderRunProjection::ToolBatchReady { .. } => {
return None
return None;
}
};
Some(lifecycle)
@@ -1535,8 +1602,11 @@ fn provider_run_terminal_remote_log_record(
}
enum ProviderDriveMessage {
Response(warp_multi_agent_api::ResponseEvent),
Lifecycle(ProviderLlmLifecycle),
Projection {
lifecycle: Option<ProviderLlmLifecycle>,
events: Vec<warp_multi_agent_api::ResponseEvent>,
acknowledgement: oneshot::Sender<Result<(), String>>,
},
Checkpoint {
checkpoint: ActiveProviderRunCheckpoint,
acknowledgement: oneshot::Sender<Result<(), String>>,
@@ -1559,6 +1629,7 @@ pub struct BlocklistAIController {
in_flight_response_streams: PendingResponseStreams,
active_provider_runs: HashMap<AIConversationId, ActiveProviderRunSlot>,
queued_provider_runs: HashMap<AIConversationId, VecDeque<QueuedProviderRun>>,
restoring_provider_runs: HashSet<AIConversationId>,
/// The ID of the terminal surface this controller is associated with.
@@ -2048,6 +2119,7 @@ impl BlocklistAIController {
terminal_model,
in_flight_response_streams: PendingResponseStreams::new(),
active_provider_runs: HashMap::new(),
queued_provider_runs: HashMap::new(),
restoring_provider_runs: HashSet::new(),
terminal_surface_id,
should_refresh_available_llms_on_stream_finish: false,
@@ -2606,6 +2678,10 @@ impl BlocklistAIController {
if self
.in_flight_response_streams
.has_active_stream_for_conversation(conversation_id, ctx)
&& !self
.active_provider_runs
.get(&conversation_id)
.is_some_and(|slot| slot.cancellation_reason.is_some())
|| self
.action_model
.as_ref(ctx)
@@ -4563,7 +4639,7 @@ impl BlocklistAIController {
.all_inputs()
.any(|input| input.is_user_query());
ctx.subscribe_to_model(&response_stream, move |me, _, event, ctx| {
me.handle_response_stream_event(
let _ = me.handle_response_stream_event(
input_contains_user_query,
event,
&response_stream_clone,
@@ -4625,15 +4701,24 @@ impl BlocklistAIController {
} else {
None
};
self.in_flight_response_streams.register_new_stream(
response_stream_id.clone(),
conversation_data.id,
response_stream.clone(),
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
ctx,
);
if provider_configs.is_some()
&& self
.active_provider_runs
.contains_key(&conversation_data.id)
{
self.in_flight_response_streams
.register_additional_stream(response_stream_id.clone(), response_stream.clone());
} else {
self.in_flight_response_streams.register_new_stream(
response_stream_id.clone(),
conversation_data.id,
response_stream.clone(),
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
ctx,
);
}
if let Some((base_provider_config, cli_provider_config)) = provider_configs {
let provider_run_id = ProviderRunId::new(format!(
"{}:{}",
@@ -4646,37 +4731,50 @@ impl BlocklistAIController {
.expect("conversation exists while starting provider run")
.get_root_task_id()
.clone();
self.active_provider_runs.insert(
conversation_data.id,
ActiveProviderRunSlot {
stream_id: response_stream_id.clone(),
response_stream,
did_input_contain_user_query: input_contains_user_query,
run_id: provider_run_id,
root_task_id,
projection_target: provider_projection_target
.expect("provider projection target was validated"),
run: None,
checkpoint: None,
turn_control: None,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
},
);
self.prepare_active_provider_run(
conversation_data.id,
response_stream_id.clone(),
base_provider_config,
cli_provider_config,
request_params.clone(),
ctx,
);
let slot = ActiveProviderRunSlot {
stream_id: response_stream_id.clone(),
response_stream,
did_input_contain_user_query: input_contains_user_query,
run_id: provider_run_id,
root_task_id,
projection_target: provider_projection_target
.expect("provider projection target was validated"),
run: None,
checkpoint: None,
turn_control: None,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
};
if self
.active_provider_runs
.contains_key(&conversation_data.id)
{
self.queued_provider_runs
.entry(conversation_data.id)
.or_default()
.push_back(QueuedProviderRun {
slot,
base_provider_config,
cli_provider_config,
request_params: request_params.clone(),
});
} else {
self.active_provider_runs.insert(conversation_data.id, slot);
self.prepare_active_provider_run(
conversation_data.id,
response_stream_id.clone(),
base_provider_config,
cli_provider_config,
request_params.clone(),
ctx,
);
}
}
// Skip the context reset for a fired queued-prompt row (`is_queued_prompt`): its
@@ -4806,21 +4904,29 @@ impl BlocklistAIController {
let Some(task) = conversation.get_task(&snapshot.projection_target.task_id) else {
return Err("restored provider projection task is missing".to_string());
};
if !task
let Some(exchange) = task
.exchanges()
.any(|exchange| exchange.id == snapshot.projection_target.exchange_id)
{
.find(|exchange| exchange.id == snapshot.projection_target.exchange_id)
else {
return Err(
"restored provider projection exchange is missing from its task"
.to_string(),
);
}
Ok(())
};
let output = exchange.output_status.output();
restored_projection_was_initialized(
output.is_some(),
output.is_some_and(|output| output.get().server_output_id.is_some()),
!exchange.added_message_ids.is_empty(),
)
});
if let Err(error) = history_validation {
self.fail_restored_provider_run(conversation_id, error, ctx);
return;
}
let projection_was_initialized = match history_validation {
Ok(initialized) => initialized,
Err(error) => {
self.fail_restored_provider_run(conversation_id, error, ctx);
return;
}
};
if let Err(error) = normalize_restored_provider_snapshot(&mut snapshot) {
self.fail_restored_provider_run(conversation_id, error, ctx);
@@ -4868,7 +4974,11 @@ impl BlocklistAIController {
ProviderRunProfile::new(cli_runtime, cli_monitor_request.clone()),
);
}
Ok::<_, anyhow::Error>(PreparedRestoredProviderRun { snapshot, profiles })
Ok::<_, anyhow::Error>(PreparedRestoredProviderRun {
snapshot,
profiles,
projection_was_initialized,
})
},
move |me, result, ctx| {
me.handle_prepared_restored_provider_run(conversation_id, result, ctx);
@@ -4886,19 +4996,18 @@ impl BlocklistAIController {
};
let evidence = {
let terminal_model = self.terminal_model.lock();
let block = terminal_model
terminal_model
.block_list()
.block_with_id(&monitor.block_id)
.ok_or_else(|| "restored provider command block is missing".to_string())?;
RestoredProviderCommandEvidence {
conversation_id: block.ai_conversation_id(),
requested_command_action_id: block.requested_command_action_id().cloned(),
cli_task_id: block.cli_subagent_task_id().cloned(),
command: block.command_to_string(),
state: block.state(),
output: block.output_to_string(),
exit_code: block.exit_code().value(),
}
.map(|block| RestoredProviderCommandEvidence {
conversation_id: block.ai_conversation_id(),
requested_command_action_id: block.requested_command_action_id().cloned(),
cli_task_id: block.cli_subagent_task_id().cloned(),
command: block.command_to_string(),
state: block.state(),
output: block.output_to_string(),
exit_code: block.exit_code().value(),
})
};
apply_restored_provider_command_evidence(conversation_id, snapshot, evidence)
}
@@ -4930,7 +5039,11 @@ impl BlocklistAIController {
self.restoring_provider_runs.remove(&conversation_id);
return;
}
let PreparedRestoredProviderRun { snapshot, profiles } = match result {
let PreparedRestoredProviderRun {
snapshot,
profiles,
projection_was_initialized,
} = match result {
Ok(prepared) => prepared,
Err(error) => {
self.fail_restored_provider_run(conversation_id, error.to_string(), ctx);
@@ -4948,6 +5061,7 @@ impl BlocklistAIController {
root_task_id,
did_input_contain_user_query,
persistence_offset,
cancellation_reason,
committed_provider_batch,
finished_provider_batch,
command_action_refs,
@@ -4960,13 +5074,21 @@ impl BlocklistAIController {
let transcript = provider_run.transcript();
let offset = persistence_offset.min(transcript.len());
let messages_sent = Arc::new(std::sync::Mutex::new(transcript[offset..].to_vec()));
let coordinator = match ProviderRunCoordinator::new(provider_run, profiles) {
let mut coordinator = match ProviderRunCoordinator::new(provider_run, profiles) {
Ok(coordinator) => coordinator,
Err(error) => {
self.fail_restored_provider_run(conversation_id, error.to_string(), ctx);
return;
}
};
if let Some(reason) = cancellation_reason {
if !coordinator.run().is_terminal() {
if let Err(error) = coordinator.run_mut().cancel(reason.to_string()) {
self.fail_restored_provider_run(conversation_id, error.to_string(), ctx);
return;
}
}
}
let model = LLMId::from(response_config.model_id.as_str());
let ai_identifiers = AIIdentifiers {
client_conversation_id: Some(conversation_id),
@@ -4984,7 +5106,7 @@ impl BlocklistAIController {
let stream_id = response_stream.as_ref(ctx).id().clone();
let response_stream_clone = response_stream.clone();
ctx.subscribe_to_model(&response_stream, move |me, _, event, ctx| {
me.handle_response_stream_event(
let _ = me.handle_response_stream_event(
did_input_contain_user_query,
event,
&response_stream_clone,
@@ -5032,7 +5154,10 @@ impl BlocklistAIController {
projection_target,
run: Some(ActiveProviderRun {
coordinator,
projector: ProviderRunResponseProjector::restored(response_config.clone()),
projector: ProviderRunResponseProjector::restored(
response_config.clone(),
projection_was_initialized,
),
response_config,
action_context,
messages_sent,
@@ -5040,7 +5165,7 @@ impl BlocklistAIController {
}),
checkpoint: None,
turn_control: None,
cancellation_reason: None,
cancellation_reason,
committed_provider_batch,
finished_provider_batch,
command_action_refs,
@@ -5320,26 +5445,29 @@ impl BlocklistAIController {
let checkpoint_sender = sender.clone();
let result = run
.coordinator
.drive_until_blocked_with_checkpoint(
.drive_until_blocked_with_acknowledgements(
turn_control,
|projection| {
if let Some(lifecycle) = provider_llm_lifecycle(&projection) {
let lifecycle = provider_llm_lifecycle(&projection);
let events = run.projector.project(projection);
let projection_sender = projection_sender.clone();
Box::pin(async move {
let events = events?;
let (acknowledgement, receiver) = oneshot::channel();
projection_sender
.try_send(ProviderDriveMessage::Lifecycle(lifecycle))
.send(ProviderDriveMessage::Projection {
lifecycle,
events,
acknowledgement,
})
.await
.map_err(|_| {
"provider lifecycle projection receiver was closed"
.to_string()
"provider projection receiver was closed".to_string()
})?;
}
for event in run.projector.project(projection)? {
projection_sender
.try_send(ProviderDriveMessage::Response(event))
.map_err(|_| {
"provider response projection receiver was closed"
.to_string()
})?;
}
Ok(())
receiver.await.map_err(|_| {
"provider projection acknowledgement was dropped".to_string()
})?
})
},
move |provider_run| {
let checkpoint_sender = checkpoint_sender.clone();
@@ -5385,29 +5513,40 @@ impl BlocklistAIController {
return;
}
match message {
ProviderDriveMessage::Response(event) => {
ProviderDriveMessage::Projection {
lifecycle,
events,
acknowledgement,
} => {
let response_stream = slot.response_stream.clone();
let did_input_contain_user_query = slot.did_input_contain_user_query;
let event = ResponseStream::projected_event(event);
self.handle_response_stream_event(
did_input_contain_user_query,
&event,
&response_stream,
ctx,
);
}
ProviderDriveMessage::Lifecycle(lifecycle) => {
let mut result = Ok(());
for event in events {
let event = ResponseStream::projected_event(event);
if let Err(error) = self.handle_response_stream_event(
did_input_contain_user_query,
&event,
&response_stream,
ctx,
) {
result = Err(error);
break;
}
}
#[cfg(not(target_family = "wasm"))]
remote_logging::log_model_event(
ctx,
provider_llm_lifecycle_remote_log_record(
conversation_id,
stream_id,
&lifecycle,
),
);
if let Some(lifecycle) = lifecycle.as_ref() {
remote_logging::log_model_event(
ctx,
provider_llm_lifecycle_remote_log_record(
conversation_id,
stream_id,
lifecycle,
),
);
}
#[cfg(target_family = "wasm")]
let _ = lifecycle;
let _ = acknowledgement.send(result);
}
ProviderDriveMessage::Checkpoint {
checkpoint,
@@ -5691,35 +5830,60 @@ impl BlocklistAIController {
batch: PendingToolBatch,
ctx: &mut ModelContext<Self>,
) {
let conversion = self
let Some(run) = self
.active_provider_runs
.get(&conversation_id)
.and_then(|slot| slot.run.as_ref())
.map(|run| {
batch
.calls
.iter()
.filter(|pending| pending.state.result().is_none())
.map(|pending| {
run.action_context
.action_from_tool_call(&pending.call)
.map(|action| {
(
action,
matches!(pending.state, PendingToolCallState::RecoveryPending),
)
})
})
.collect::<Result<Vec<_>, _>>()
});
let converted_actions = match conversion {
Some(Ok(actions)) => actions,
Some(Err(message)) => {
self.fail_active_provider_run(conversation_id, message, ctx);
.get_mut(&conversation_id)
.and_then(|slot| slot.run.as_mut())
else {
return;
};
let (converted_actions, invalid_results) =
convert_provider_tool_batch(&run.action_context, &batch);
for result in &invalid_results {
if let Err(error) = run
.coordinator
.run_mut()
.complete_tool(&batch.work_id, result.clone())
{
self.fail_active_provider_run(
conversation_id,
format!("failed to record invalid provider tool input: {error}"),
ctx,
);
return;
}
None => return,
};
}
if converted_actions.is_empty() {
if let Err(error) = run.coordinator.run_mut().commit_tool_batch(&batch.work_id) {
self.fail_active_provider_run(
conversation_id,
format!("failed to commit invalid provider tool batch: {error}"),
ctx,
);
return;
}
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
self.fail_active_provider_run(
conversation_id,
format!("failed to persist invalid provider tool results: {error}"),
ctx,
);
return;
}
self.drive_active_provider_run(conversation_id, ctx);
return;
}
let mut executable_batch = batch.clone();
for pending in &mut executable_batch.calls {
if let Some(result) = invalid_results
.iter()
.find(|result| result.call_id == pending.call.id)
{
pending.state = PendingToolCallState::Resolved {
result: result.clone(),
};
}
}
let stream_id = self.active_provider_runs[&conversation_id]
.stream_id
.clone();
@@ -5806,7 +5970,7 @@ impl BlocklistAIController {
actions,
recovery_action_ids,
conversation_id,
&batch,
&executable_batch,
ctx,
)
});
@@ -6038,6 +6202,33 @@ impl BlocklistAIController {
}
}
fn detach_cancelled_provider_command(
&mut self,
conversation_id: AIConversationId,
block_id: &BlockId,
ctx: &mut ModelContext<Self>,
) {
let detached = {
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if active_block.id() == block_id
&& active_block.ai_conversation_id() == Some(conversation_id)
&& active_block.is_active_and_long_running()
{
active_block.set_user_control_with_stop_reason();
true
} else {
false
}
};
self.deactivate_provider_cli_task(conversation_id, block_id, ctx);
if !detached {
log::warn!(
"Could not detach cancelled provider command for conversation {conversation_id:?} block {block_id:?}"
);
}
}
fn handle_provider_actions_finished(
&mut self,
conversation_id: AIConversationId,
@@ -6219,7 +6410,7 @@ impl BlocklistAIController {
let did_input_contain_user_query = slot.did_input_contain_user_query;
for event in events {
let event = ResponseStream::projected_event(event);
self.handle_response_stream_event(
let _ = self.handle_response_stream_event(
did_input_contain_user_query,
&event,
&response_stream,
@@ -6237,9 +6428,35 @@ impl BlocklistAIController {
),
);
match outcome {
ProviderRunOutcome::Completed(_) => {
self.finalize_completed_provider_conversation(conversation_id, ctx);
}
ProviderRunOutcome::Completed(completion) => match completion.stop_reason {
StopReason::Completed => {
self.finalize_completed_provider_conversation(conversation_id, ctx);
}
StopReason::Cancelled => {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.update_conversation_status(
self.terminal_surface_id,
conversation_id,
ConversationStatus::Cancelled,
ctx,
);
});
}
StopReason::MaxTokens
| StopReason::ContextWindowExceeded
| StopReason::Refusal
| StopReason::ToolLoopLimit
| StopReason::Other(_) => {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.update_conversation_status(
self.terminal_surface_id,
conversation_id,
ConversationStatus::Error,
ctx,
);
});
}
},
// Failed outcomes are finalized by the projected InternalError event.
ProviderRunOutcome::Failed(_) => {}
ProviderRunOutcome::Cancelled { .. } => {
@@ -6314,6 +6531,13 @@ impl BlocklistAIController {
response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>,
) {
if !self
.active_provider_runs
.get(&conversation_id)
.is_some_and(|slot| &slot.stream_id == stream_id)
{
return;
}
if let Err(error) = self.clear_persisted_active_provider_run(conversation_id, ctx) {
log::error!("Failed to clear persisted provider run during cleanup: {error}");
}
@@ -6334,6 +6558,38 @@ impl BlocklistAIController {
request_usage_model.refresh_request_usage_async(ctx);
});
self.maybe_refresh_ai_overages(ctx);
self.start_next_queued_provider_run(conversation_id, ctx);
}
fn start_next_queued_provider_run(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let next = self
.queued_provider_runs
.get_mut(&conversation_id)
.and_then(VecDeque::pop_front);
if self
.queued_provider_runs
.get(&conversation_id)
.is_some_and(VecDeque::is_empty)
{
self.queued_provider_runs.remove(&conversation_id);
}
let Some(next) = next else {
return;
};
let stream_id = next.slot.stream_id.clone();
self.active_provider_runs.insert(conversation_id, next.slot);
self.prepare_active_provider_run(
conversation_id,
stream_id,
next.base_provider_config,
next.cli_provider_config,
next.request_params,
ctx,
);
}
fn cancel_active_provider_run(
@@ -6342,53 +6598,59 @@ impl BlocklistAIController {
reason: CancellationReason,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(mut slot) = self.active_provider_runs.remove(&conversation_id) else {
let cancellation_outcome = reason.conversation_outcome();
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
return false;
};
slot.cancellation_reason = Some(reason);
let command_block_id = if matches!(cancellation_outcome, CancellationOutcome::Cancelled) {
let monitor = slot.command_monitor.take();
if let Some(monitor) = &monitor {
slot.command_action_refs
.remove(&monitor.initial_requested_command_action_id);
}
slot.pending_monitor_observation = None;
slot.pending_command_completion = None;
monitor.map(|monitor| monitor.block_id)
} else {
None
};
if let Some(turn_control) = &slot.turn_control {
let _ = turn_control.try_send(TurnCommand::Cancel);
}
if let Some(mut run) = slot.run.take() {
let should_drive = if let Some(run) = slot.run.as_mut() {
if !run.coordinator.run().is_terminal() {
let _ = run.coordinator.run_mut().cancel(reason.to_string());
}
if let Ok(mut messages_sent) = run.messages_sent.lock() {
let transcript = run.coordinator.run().transcript();
let offset = run.persistence_offset.min(transcript.len());
*messages_sent = transcript[offset..].to_vec();
}
true
} else {
false
};
// Keep the terminal run and its slot durable until the normal driver path projects the
// cancellation and finalizes it through `finish_active_provider_run`.
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
log::error!("Failed to persist provider cancellation: {error}");
}
self.action_model.update(ctx, |action_model, ctx| {
action_model.cancel_all_pending_actions(conversation_id, Some(reason), ctx);
});
let cancellation_outcome = reason.conversation_outcome();
if FeatureFlag::AgentSharedSessions.is_enabled()
&& !matches!(cancellation_outcome, CancellationOutcome::KeepInProgress)
{
self.send_cancellation_to_viewers(ctx);
}
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.mark_response_stream_cancelled(
&slot.stream_id,
conversation_id,
self.terminal_surface_id,
reason,
ctx,
);
});
if matches!(cancellation_outcome, CancellationOutcome::Cancelled) {
self.set_input_mode_for_cancellation(ctx);
if let Some(block_id) = command_block_id {
self.detach_cancelled_provider_command(conversation_id, &block_id, ctx);
}
}
if should_drive {
self.drive_active_provider_run(conversation_id, ctx);
}
self.cleanup_active_provider_run(
conversation_id,
&slot.stream_id,
&slot.response_stream,
ctx,
);
true
}
@@ -6535,7 +6797,7 @@ impl BlocklistAIController {
) {
let stream_clone = stream.clone();
ctx.subscribe_to_model(&stream, move |me, _, event, ctx| {
me.handle_response_stream_event(false, event, &stream_clone, ctx);
let _ = me.handle_response_stream_event(false, event, &stream_clone, ctx);
});
self.in_flight_response_streams.register_new_stream(
stream_id,
@@ -6739,7 +7001,7 @@ impl BlocklistAIController {
event: &ResponseStreamEvent,
response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>,
) {
) -> Result<(), String> {
let stream_id = response_stream.as_ref(ctx).id().clone();
match event {
@@ -6749,14 +7011,16 @@ impl BlocklistAIController {
.conversation_for_response_stream(&stream_id)
else {
log::warn!("Could not find conversation for response stream: {stream_id:?}");
return;
return Err(format!(
"could not find conversation for response stream {stream_id:?}"
));
};
let Some(event) = event.consume() else {
debug_assert!(
false,
"This model should only have a single subscriber that takes ownership over the event."
);
return;
return Err("response stream event was already consumed".to_string());
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
match event {
@@ -6794,7 +7058,7 @@ impl BlocklistAIController {
}
}
let Some(event) = event.r#type else {
return;
return Err("response event did not contain a type".to_string());
};
match event {
warp_multi_agent_api::response_event::Type::Init(init_event) => {
@@ -6909,6 +7173,9 @@ impl BlocklistAIController {
log::error!(
"Failed to apply client actions to conversation: {e:?}"
);
return Err(format!(
"failed to apply provider client actions: {e:?}"
));
}
}
}
@@ -6954,7 +7221,9 @@ impl BlocklistAIController {
log::warn!(
"Could not find conversation for response stream: {stream_id:?}"
);
return;
return Err(format!(
"could not find conversation for response stream {stream_id:?}"
));
};
id
}
@@ -6980,7 +7249,7 @@ impl BlocklistAIController {
})
else {
log::warn!("Conversation not found.");
return;
return Err("conversation not found for completed response stream".to_string());
};
#[cfg(not(target_family = "wasm"))]
if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() {
@@ -7036,7 +7305,7 @@ impl BlocklistAIController {
for new_exchange_id in new_exchange_ids {
let Some(exchange) = exchanges.exchange_with_id(new_exchange_id) else {
log::warn!("Exchange not found.");
return;
return Err("exchange not found for completed response stream".to_string());
};
was_passive_request |= exchange.has_passive_request();
is_any_exchange_unfinished |= !exchange.output_status.is_finished();
@@ -7328,6 +7597,7 @@ impl BlocklistAIController {
self.maybe_refresh_ai_overages(ctx);
}
}
Ok(())
}
/// Sets the terminal input state after an AI request is cancelled.
@@ -91,6 +91,14 @@ impl PendingResponseStreams {
self.streams.insert(stream_id, stream);
}
pub fn register_additional_stream(
&mut self,
stream_id: ResponseStreamId,
stream: ModelHandle<ResponseStream>,
) {
self.streams.insert(stream_id, stream);
}
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id);
}
@@ -142,9 +150,11 @@ impl PendingResponseStreams {
for response_stream in streams_to_cancel.into_iter() {
log::info!(
"Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
reason={reason}"
);
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
}
response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
@@ -354,7 +354,7 @@ impl BlocklistAIController {
if self
.action_model
.as_ref(ctx)
.get_action_result(&result.id)
.get_action_result(conversation_id, &result.id)
.is_none()
{
self.action_model.update(ctx, |action_model, ctx| {
+454 -9
View File
@@ -25,6 +25,7 @@ use crate::ai::agent::{
WriteToLongRunningShellCommandResult,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::action_model::StartAgentWaitPolicy;
use crate::ai::blocklist::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, PendingAttachment, PendingFile, RequestInput,
ResponseStream, ResponseStreamId, StartAgentExecutor,
@@ -265,6 +266,7 @@ fn provider_snapshot(conversation_id: AIConversationId) -> super::ActiveProvider
root_task_id: task_id,
did_input_contain_user_query: true,
persistence_offset: 0,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
@@ -275,6 +277,278 @@ fn provider_snapshot(conversation_id: AIConversationId) -> super::ActiveProvider
}
}
#[test]
fn provider_snapshot_persists_cancellation_reason() {
let conversation_id = AIConversationId::new();
let mut snapshot = provider_snapshot(conversation_id);
snapshot.cancellation_reason = Some(CancellationReason::ManuallyCancelled);
let restored = super::ActiveProviderRunSnapshot::parse(
&serde_json::to_string(&snapshot).expect("cancellation snapshot should serialize"),
)
.expect("cancellation snapshot should parse");
assert_eq!(
restored.cancellation_reason,
Some(CancellationReason::ManuallyCancelled)
);
assert!(!restored.run.is_terminal());
}
#[test]
fn cancelling_provider_startup_keeps_slot_and_durable_cancellation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let terminal_surface_id = terminal.id();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(
terminal_surface_id,
false,
false,
false,
ctx,
)
});
let snapshot = provider_snapshot(conversation_id);
let stream_id = ResponseStreamId::new_for_test();
let response_stream =
ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
let checkpoint = super::ActiveProviderRunCheckpoint {
run: snapshot.run.clone(),
base_request: snapshot.base_request.clone(),
cli_monitor_request: snapshot.cli_monitor_request.clone(),
response_config: snapshot.response_config.clone(),
action_context: snapshot.action_context.clone(),
persistence_offset: snapshot.persistence_offset,
};
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_runs.insert(
conversation_id,
super::ActiveProviderRunSlot {
stream_id,
response_stream,
did_input_contain_user_query: snapshot.did_input_contain_user_query,
run_id: snapshot.run.id().clone(),
root_task_id: snapshot.root_task_id,
projection_target: snapshot.projection_target,
run: None,
checkpoint: Some(checkpoint),
turn_control: None,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
},
);
assert!(controller.cancel_active_provider_run(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
));
assert_eq!(
controller
.active_provider_runs
.get(&conversation_id)
.and_then(|slot| slot.cancellation_reason),
Some(CancellationReason::ManuallyCancelled)
);
});
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.expect("cancelled provider conversation should remain durable");
let persisted = super::ActiveProviderRunSnapshot::parse(
conversation
.active_provider_run_json()
.expect("cancelled provider run should remain checkpointed"),
)
.expect("persisted cancellation should parse");
assert_eq!(
persisted.cancellation_reason,
Some(CancellationReason::ManuallyCancelled)
);
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
});
});
}
#[test]
fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let terminal_surface_id = terminal.id();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(
terminal_surface_id,
false,
false,
false,
ctx,
)
});
let mut old_snapshot = provider_snapshot(conversation_id);
start_snapshot_tool(&mut old_snapshot, "old-tool");
assert!(matches!(
old_snapshot.run.state(),
ProviderRunState::AwaitingTools { .. }
));
let old_stream_id = ResponseStreamId::new_for_test();
let old_response_stream =
ctx.add_model(|_| ResponseStream::new_for_test(old_stream_id.clone()));
let new_stream_id = ResponseStreamId::new_for_test();
let new_response_stream =
ctx.add_model(|_| ResponseStream::new_for_test(new_stream_id.clone()));
let new_snapshot = provider_snapshot(conversation_id);
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.active_provider_runs.insert(
conversation_id,
super::ActiveProviderRunSlot {
stream_id: old_stream_id.clone(),
response_stream: old_response_stream.clone(),
did_input_contain_user_query: true,
run_id: old_snapshot.run.id().clone(),
root_task_id: old_snapshot.root_task_id.clone(),
projection_target: old_snapshot.projection_target.clone(),
run: None,
checkpoint: Some(super::ActiveProviderRunCheckpoint {
run: old_snapshot.run.clone(),
base_request: old_snapshot.base_request.clone(),
cli_monitor_request: old_snapshot.cli_monitor_request.clone(),
response_config: old_snapshot.response_config.clone(),
action_context: old_snapshot.action_context.clone(),
persistence_offset: old_snapshot.persistence_offset,
}),
turn_control: None,
cancellation_reason: Some(CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
}),
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
},
);
controller
.queued_provider_runs
.entry(conversation_id)
.or_default()
.push_back(super::QueuedProviderRun {
slot: super::ActiveProviderRunSlot {
stream_id: new_stream_id.clone(),
response_stream: new_response_stream,
did_input_contain_user_query: true,
run_id: new_snapshot.run.id().clone(),
root_task_id: new_snapshot.root_task_id,
projection_target: new_snapshot.projection_target,
run: None,
checkpoint: None,
turn_control: None,
cancellation_reason: None,
committed_provider_batch: None,
finished_provider_batch: None,
command_action_refs: HashMap::new(),
command_monitor: None,
pending_monitor_observation: None,
pending_command_completion: None,
monitor_prose_continuations: 0,
},
base_provider_config: crate::ai::provider::ProviderConfig::None,
cli_provider_config: crate::ai::provider::ProviderConfig::None,
request_params: crate::ai::agent::api::RequestParams::new_for_test(),
});
assert_eq!(
controller.active_provider_runs[&conversation_id].stream_id,
old_stream_id
);
controller.cleanup_active_provider_run(
conversation_id,
&old_stream_id,
&old_response_stream,
ctx,
);
assert_eq!(
controller.active_provider_runs[&conversation_id].stream_id,
new_stream_id
);
assert!(!controller
.queued_provider_runs
.contains_key(&conversation_id));
// A delayed callback from the old generation cannot remove its replacement.
controller.cleanup_active_provider_run(
conversation_id,
&old_stream_id,
&old_response_stream,
ctx,
);
assert_eq!(
controller.active_provider_runs[&conversation_id].stream_id,
new_stream_id
);
});
});
});
}
#[test]
fn cancelled_provider_command_detaches_running_process_to_user() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let conversation_id = AIConversationId::new();
let task_id = TaskId::new("provider-command-monitor".to_owned());
let block_id = {
let mut terminal_model = terminal.model.lock();
terminal_model.simulate_long_running_block("sleep 100", "running");
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.set_is_agent_tagged_in(true);
active_block
.set_agent_interaction_mode_for_agent_monitored_command(
&task_id,
conversation_id,
)
.expect("command should become agent monitored");
active_block.id().clone()
};
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.detach_cancelled_provider_command(conversation_id, &block_id, ctx);
});
let terminal_model = terminal.model.lock();
let active_block = terminal_model.block_list().active_block();
assert!(!active_block.is_agent_in_control());
assert!(active_block
.long_running_control_state()
.and_then(|state| state.user_take_over_reason())
.is_some_and(|reason| reason.is_stop()));
assert!(active_block.is_active_and_long_running());
});
});
}
fn start_snapshot_tool(
snapshot: &mut super::ActiveProviderRunSnapshot,
call_id: &str,
@@ -305,6 +579,105 @@ fn start_snapshot_tool(
batch.work_id
}
#[test]
fn malformed_provider_tool_inputs_become_correlated_errors_without_dropping_valid_calls() {
let conversation_id = AIConversationId::new();
let mut snapshot = provider_snapshot(conversation_id);
let Some(ProviderRunStep::CallModel(call)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider model call");
};
snapshot
.run
.accept_model_turn(
&call.work_id,
CompletedModelTurn {
assistant_content: vec![],
tool_calls: vec![
ToolCall {
id: "bad-read".to_owned(),
name: "read_files".to_owned(),
arguments: serde_json::json!({"files": "not-an-array"}),
},
ToolCall {
id: "good-grep".to_owned(),
name: "grep".to_owned(),
arguments: serde_json::json!({"queries": ["ProviderRun"]}),
},
],
usage: Usage::default(),
stop_reason: StopReason::Completed,
advertised_tools: BTreeSet::from(["grep".to_owned(), "read_files".to_owned()]),
},
)
.unwrap();
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider tool batch");
};
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "good-grep");
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].call_id, "bad-read");
assert_eq!(errors[0].status, galaxy_agent_core::ToolResultStatus::Error);
assert!(errors[0].content.contains("expected an array"));
}
#[test]
fn malformed_provider_tool_error_can_be_committed_and_run_continues() {
let conversation_id = AIConversationId::new();
let mut snapshot = provider_snapshot(conversation_id);
let Some(ProviderRunStep::CallModel(call)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider model call");
};
snapshot
.run
.accept_model_turn(
&call.work_id,
CompletedModelTurn {
assistant_content: vec![],
tool_calls: vec![ToolCall {
id: "bad-read".to_owned(),
name: "read_files".to_owned(),
arguments: serde_json::json!({}),
}],
usage: Usage::default(),
stop_reason: StopReason::Completed,
advertised_tools: BTreeSet::from(["read_files".to_owned()]),
},
)
.unwrap();
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider tool batch");
};
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch);
assert!(actions.is_empty());
snapshot
.run
.complete_tool(&batch.work_id, errors[0].clone())
.unwrap();
snapshot.run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(
snapshot.run.state(),
ProviderRunState::ReadyToCallModel
));
let MessageContent::MultiPart(parts) = &snapshot.run.transcript().last().unwrap().content
else {
panic!("expected correlated tool result");
};
assert!(matches!(
&parts[0],
ContentPart::ToolResult {
tool_use_id,
is_error: true,
..
} if tool_use_id == "bad-read"
));
}
fn attach_snapshot_command_monitor(
snapshot: &mut super::ActiveProviderRunSnapshot,
conversation_id: AIConversationId,
@@ -548,7 +921,7 @@ fn restored_active_command_rebuilds_monitor_observation() {
super::apply_restored_provider_command_evidence(
conversation_id,
&mut snapshot,
super::RestoredProviderCommandEvidence {
Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id),
cli_task_id: Some(cli_task_id.clone()),
@@ -556,7 +929,7 @@ fn restored_active_command_rebuilds_monitor_observation() {
state: BlockState::Executing,
output: "running".to_owned(),
exit_code: 0,
},
}),
)
.unwrap();
@@ -582,7 +955,7 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
super::apply_restored_provider_command_evidence(
conversation_id,
&mut snapshot,
super::RestoredProviderCommandEvidence {
Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id.clone()),
cli_task_id: Some(cli_task_id),
@@ -590,7 +963,7 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
state: BlockState::DoneWithExecution,
output: "done".to_owned(),
exit_code: 17,
},
}),
)
.unwrap();
@@ -608,6 +981,78 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
assert_eq!(completion.exit_code, 17);
}
#[test]
fn restored_missing_command_block_becomes_interrupted_completion_evidence() {
let conversation_id = AIConversationId::new();
let mut snapshot = provider_snapshot(conversation_id);
let (action_id, block_id, _) = attach_snapshot_command_monitor(&mut snapshot, conversation_id);
snapshot.pending_monitor_observation = Some(super::PendingProviderMonitorObservation {
block_id: block_id.clone(),
cli_task_id: TaskId::new("stale-cli-task".to_owned()),
});
super::apply_restored_provider_command_evidence(conversation_id, &mut snapshot, None).unwrap();
assert!(snapshot.pending_monitor_observation.is_none());
let completion = snapshot
.pending_command_completion
.expect("missing terminal block should become interrupted-command evidence");
assert_eq!(completion.block_id, block_id);
assert_eq!(
completion.initial_requested_command_action_id,
Some(action_id)
);
assert_eq!(completion.command, "sleep 10");
assert_eq!(completion.exit_code, 130);
assert!(completion.output.contains("interrupted"));
}
#[test]
fn restored_evidence_is_ignored_without_a_command_monitor() {
let conversation_id = AIConversationId::new();
let mut snapshot = provider_snapshot(conversation_id);
let evidence = super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id),
requested_command_action_id: None,
cli_task_id: None,
command: "sleep 10".to_owned(),
state: BlockState::Executing,
output: "running".to_owned(),
exit_code: 0,
};
super::apply_restored_provider_command_evidence(conversation_id, &mut snapshot, Some(evidence))
.unwrap();
assert!(snapshot.pending_monitor_observation.is_none());
assert!(snapshot.pending_command_completion.is_none());
}
#[test]
fn restored_projection_accepts_empty_or_complete_and_rejects_partial_state() {
assert_eq!(
super::restored_projection_was_initialized(false, false, false).unwrap(),
false
);
assert_eq!(
super::restored_projection_was_initialized(true, true, false).unwrap(),
true
);
assert_eq!(
super::restored_projection_was_initialized(true, true, true).unwrap(),
true
);
for state in [
(false, false, true),
(false, true, false),
(true, false, false),
] {
assert_eq!(
super::restored_projection_was_initialized(state.0, state.1, state.2).unwrap_err(),
"restored provider projection exchange is partially initialized"
);
}
}
#[test]
fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
let conversation_id = AIConversationId::new();
@@ -619,7 +1064,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
super::apply_restored_provider_command_evidence(
conversation_id,
&mut snapshot,
super::RestoredProviderCommandEvidence {
Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(AIConversationId::new()),
requested_command_action_id: Some(action_id.clone()),
cli_task_id: Some(cli_task_id.clone()),
@@ -627,7 +1072,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
state: BlockState::Executing,
output: String::new(),
exit_code: 0,
},
}),
)
.unwrap_err(),
"restored provider command block identity does not match"
@@ -636,7 +1081,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
super::apply_restored_provider_command_evidence(
conversation_id,
&mut snapshot,
super::RestoredProviderCommandEvidence {
Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id),
cli_task_id: Some(cli_task_id),
@@ -644,7 +1089,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
state: BlockState::Background,
output: String::new(),
exit_code: 0,
},
}),
)
.unwrap_err(),
"restored provider command block has an invalid state"
@@ -2042,7 +2487,7 @@ fn completed_provider_run_with_prior_action_resolves_child_completion_wait() {
"child".to_owned(),
parent_conversation_id,
child_conversation_id,
None,
StartAgentWaitPolicy::Completion,
ctx,
)
});
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
};
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
if event.action_id() != me.action_id() {
if event.action_id() != me.action_id()
|| event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.conversation_id)
{
return;
}
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
/// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
let action_model = self.action_model.as_ref(app);
if let Some(status) = action_model.get_action_status(self.action_id()) {
if let Some(status) = action_model.get_action_status(self.conversation_id, self.action_id())
{
return Some(status);
}
@@ -695,12 +695,26 @@ impl CodeDiffView {
session_platform,
ctx,
);
let action_id = (*action_id).clone();
ctx.subscribe_to_model(
&action_model,
move |me, action_model, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => {
match action_model.as_ref(ctx).get_action_status(&me.action_id) {
BlocklistAIActionEvent::FinishedAction {
action_id: event_action_id,
conversation_id: event_conversation_id,
..
} if !me.is_complete()
&& *event_action_id == me.action_id
&& me.identifiers.client_conversation_id == Some(*event_conversation_id) =>
{
let Some(conversation_id) = me.identifiers.client_conversation_id else {
return;
};
match action_model
.as_ref(ctx)
.get_action_status(conversation_id, &me.action_id)
{
Some(AIActionStatus::Blocked) => {
me.state = CodeDiffState::WaitingForUser;
ctx.notify();
@@ -412,7 +412,7 @@ impl RequestedCommandView {
let is_finished = action_model
.as_ref(ctx)
.get_action_result(&action_id)
.get_action_result(client_ids.conversation_id, &action_id)
.is_some();
if !is_finished {
@@ -424,16 +424,24 @@ impl RequestedCommandView {
ctx.notify();
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id, ..
} if *action_id == me.action_id => {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
if me.action_type.is_requested_command() {
me.ensure_editor(ctx);
}
me.set_is_header_expanded(true, ctx);
ctx.notify();
}
BlocklistAIActionEvent::ExecutingAction { action_id, .. }
if *action_id == me.action_id =>
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
// For shared-session viewers, sync the command text from the action when it starts executing.
if me.action_model.as_ref(ctx).is_view_only() {
@@ -467,11 +475,15 @@ impl RequestedCommandView {
}
ctx.notify();
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id => {
let Some(action_result) = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.cloned()
else {
log::info!("Got finished action event without result: {action_id}.");
@@ -724,7 +736,7 @@ impl RequestedCommandView {
fn is_waiting_for_user_confirmation(&self, app: &AppContext) -> bool {
self.action_model
.as_ref(app)
.get_action_status(&self.action_id)
.get_action_status(self.client_ids.conversation_id, &self.action_id)
.is_some_and(|status| status.is_blocked())
}
@@ -750,7 +762,9 @@ impl RequestedCommandView {
let Some(mouse_state_handle) =
self.citation_state_handles.get(copied_citation).cloned()
else {
log::warn!("Tried to retrieve mouse state handle for citation, but no mouse state handle exists.");
log::warn!(
"Tried to retrieve mouse state handle for citation, but no mouse state handle exists."
);
return None;
};
render_citation(
@@ -1108,7 +1122,7 @@ impl RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let mut title: Cow<'static, str>;
let mut font_override = None;
@@ -1457,7 +1471,7 @@ impl View for RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let is_last_output_message_in_output = self
.block_model
@@ -1635,6 +1649,7 @@ impl View for RequestedCommandView {
let container = render_tool_pane_shell(
content.finish(),
has_highlighted_border,
self.is_header_expanded,
should_remove_bottom_margin,
app,
);
@@ -489,29 +489,36 @@ impl RunAgentsCardView {
// Re-render when this action finishes or becomes blocked.
let action_id_for_action_events = action_id.clone();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| {
if event.conversation_id().is_some_and(|conversation_id| {
me.block_model.conversation_id(ctx) != Some(conversation_id)
}) {
return;
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
}
ctx.notify();
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
}
ctx.notify();
}
_ => {}
}
_ => {}
});
// Repopulate the model picker when available Warp LLMs change.
@@ -713,8 +720,11 @@ impl RunAgentsCardView {
let request = self.state.to_request();
self.emit_decision(RunAgentsCardDecision::Accept, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.execute_run_agents(&action_id, request, action_ctx);
action_model.execute_run_agents(conversation_id, &action_id, request, action_ctx);
});
}
@@ -806,10 +816,13 @@ impl RunAgentsCardView {
if self.block_model.is_restored() {
return;
}
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
if matches!(
self.action_model
.as_ref(ctx)
.get_action_status(&self.action_id),
.get_action_status(conversation_id, &self.action_id),
Some(AIActionStatus::Finished(_)) | Some(AIActionStatus::RunningAsync)
) {
return;
@@ -1093,9 +1106,13 @@ impl View for RunAgentsCardView {
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.block_model
.conversation_id(app)
.and_then(|conversation_id| {
self.action_model
.as_ref(app)
.get_action_status(conversation_id, &self.action_id)
});
if let Some(AIActionStatus::Finished(result)) = &status {
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
@@ -1208,8 +1225,16 @@ impl TypedActionView for RunAgentsCardView {
RunAgentsCardViewAction::AcceptWithoutOrchestration => {
self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.deny_run_agents(&action_id, String::new(), action_ctx);
action_model.deny_run_agents(
conversation_id,
&action_id,
String::new(),
action_ctx,
);
});
}
RunAgentsCardViewAction::ToggleAcceptMenu => {
@@ -1537,14 +1562,31 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
.count();
if launched == total {
let completed = agents
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Completed { .. }))
.count();
let successful = launched + completed;
if completed > 0 && completed == total {
let label = if total == 1 {
"Completed 1 agent".to_string()
} else {
format!("Completed {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 && completed > 0 {
(
format!("Completed {completed} of {total} agents"),
StatusKind::Mixed,
)
} else if successful == total {
let label = if total == 1 {
"Spawned 1 agent".to_string()
} else {
format!("Spawned {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 {
} else if successful == 0 {
// Every child failed to launch: surface a terminal failure
// rather than the in-progress-looking mixed state.
let label = if total == 1 {
@@ -1555,7 +1597,7 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
(label, StatusKind::Failure)
} else {
(
format!("Spawned {launched} of {total} agents"),
format!("Spawned {successful} of {total} agents"),
StatusKind::Mixed,
)
}
@@ -1713,7 +1755,8 @@ fn render_run_agents_child_row(
app: &AppContext,
) -> Box<dyn Element> {
let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id } => {
RunAgentsAgentOutcomeKind::Launched { agent_id }
| RunAgentsAgentOutcomeKind::Completed { agent_id, .. } => {
conversation_id_for_agent_id(agent_id, app)
}
RunAgentsAgentOutcomeKind::Failed { .. } => None,
@@ -1757,6 +1800,9 @@ fn render_run_agents_child_row(
RunAgentsAgentOutcomeKind::Launched { .. } => {
(ConversationStatus::Success, "Started".to_string())
}
RunAgentsAgentOutcomeKind::Completed { .. } => {
(ConversationStatus::Success, "Completed".to_string())
}
RunAgentsAgentOutcomeKind::Failed { error } => (
ConversationStatus::Error,
if error.trim().is_empty() {
@@ -327,6 +327,16 @@ mod format_terminal_state_tests {
}
}
fn completed(name: &str, agent_id: &str) -> RunAgentsAgentOutcome {
RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: agent_id.to_string(),
output: format!("{name} output"),
},
}
}
fn launched_result(agents: Vec<RunAgentsAgentOutcome>) -> RunAgentsResult {
RunAgentsResult::Launched {
model_id: "auto".to_string(),
@@ -368,6 +378,30 @@ mod format_terminal_state_tests {
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_completed_uses_completed_label_and_success_status() {
let result = launched_result(vec![
completed("a", "a-1"),
completed("b", "a-2"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 3 agents");
assert!(matches!(kind, StatusKind::Success));
}
#[test]
fn mixed_completed_and_failed_uses_completed_label_and_mixed_status() {
let result = launched_result(vec![
completed("a", "a-1"),
failed("b", "boom"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 2 of 3 agents");
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_failed_uses_failure_status_not_mixed() {
let result = launched_result(vec![
@@ -14,6 +14,7 @@ use crate::ai::blocklist::block::view_impl::{
pub(crate) fn render_tool_pane_shell(
content: Box<dyn Element>,
has_highlighted_border: bool,
spans_conversation_width: bool,
should_remove_bottom_margin: bool,
app: &AppContext,
) -> Box<dyn Element> {
@@ -25,7 +26,7 @@ pub(crate) fn render_tool_pane_shell(
};
Container::new(content)
.with_margin_left(if has_highlighted_border {
.with_margin_left(if has_highlighted_border || spans_conversation_width {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.