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
+8 -1
View File
@@ -40,6 +40,7 @@ Environment variables:
- Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including: - Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including:
- `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails) - `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails)
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory - Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
- Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
### AI Provider Architecture ### AI Provider Architecture
@@ -139,7 +140,10 @@ Key invariants:
- Direct-provider `RequestFileEdits` views must register from streaming output before provider-run completion; preprocessing results must survive delayed view registration, and `NotReady` retries must remain automatic rather than emitting a synthetic user permission decision - Direct-provider `RequestFileEdits` views must register from streaming output before provider-run completion; preprocessing results must survive delayed view registration, and `NotReady` retries must remain automatic rather than emitting a synthetic user permission decision
- A clean direct-provider `ProviderRunOutcome::Completed` explicitly finalizes the conversation as `Success` after terminal output projection, even if earlier turns added tool actions; child-completion waits rely on that status - A clean direct-provider `ProviderRunOutcome::Completed` explicitly finalizes the conversation as `Success` after terminal output projection, even if earlier turns added tool actions; child-completion waits rely on that status
- Provider actions and results must correlate by `(conversation_id, run_id, epoch, call_id)`; stale or duplicate callbacks must not advance a run - Provider actions and results must correlate by `(conversation_id, run_id, epoch, call_id)`; stale or duplicate callbacks must not advance a run
- Active provider runs must checkpoint before external work, persist without credentials, normalize unsafe restored states, and reconcile command state before continuing - Action status/result lookups and archived results are keyed by `(conversation_id, action_id)`; callers must supply the owning conversation and must not fall back to a global action-ID search
- Action blocked/executing/finished events carry `conversation_id`; UI subscribers must match it, and CLI shell-control mutations must also match the active block's requested-command action ID
- Active provider runs must checkpoint before external work, persist without credentials, normalize unsafe restored states, and reconcile command state before continuing; cancellation intent stays checkpointed until the terminal outcome is projected and `finish_active_provider_run` performs cleanup
- Same-conversation direct-provider follow-ups queue behind the cancelling generation; the old run keeps the active slot until terminal projection and cleanup, then the next generation starts, and stale callbacks are ignored by stream identity
- Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs` - Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
- Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose - Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose
- Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation - Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation
@@ -151,10 +155,13 @@ Key invariants:
- Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run - Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run
- Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction - Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
- Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run - Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
- A direct-provider command completion is only queued when the terminal reports it; the CLI task remains active until the provider run applies that completion at a safe boundary and deactivates it
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration - Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun` - ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
- Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools - Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
- Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits - Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits
- `StartAgentWaitPolicy` is selected from child execution mode, not parent `run_id`: local children wait for completion and only remote/hosted children use startup acknowledgement
- Hosted `RunAgents` startup timeouts detach the exact `StartAgentRequestId`; late launch callbacks must not register the child after the timeout result, while children linked before cancellation remain independently running
### Platform Setup ### Platform Setup
- `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided. - `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided.
+2 -1
View File
@@ -1930,7 +1930,8 @@ impl AIConversation {
) -> String { ) -> String {
let mut result = Vec::new(); let mut result = Vec::new();
for exchange in self.all_exchanges() { for exchange in self.all_exchanges() {
let formatted_exchange = exchange.format_for_copy(action_model); let formatted_exchange =
exchange.format_for_copy_for_conversation(action_model, Some(self.id()));
if !formatted_exchange.is_empty() { if !formatted_exchange.is_empty() {
result.push(formatted_exchange); result.push(formatted_exchange);
} }
+38 -4
View File
@@ -581,6 +581,14 @@ impl AIAgentOutput {
pub fn format_for_copy( pub fn format_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_was_action = false; let mut last_was_action = false;
@@ -612,8 +620,12 @@ impl AIAgentOutput {
} }
AIAgentOutputMessageType::Action(action) => { AIAgentOutputMessageType::Action(action) => {
// Include action results from the action model if available // Include action results from the action model if available
if let Some(action_model) = action_model { if let (Some(action_model), Some(conversation_id)) =
if let Some(action_result) = action_model.get_action_result(&action.id) { (action_model, conversation_id)
{
if let Some(action_result) =
action_model.get_action_result(conversation_id, &action.id)
{
result.push(format!("{}", MarkdownActionResult(&action_result.result))); result.push(format!("{}", MarkdownActionResult(&action_result.result)));
// Add an extra newline after tool call results for readability // Add an extra newline after tool call results for readability
result.push(String::new()); result.push(String::new());
@@ -1222,6 +1234,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
write!(f, "\n_Command cancelled_") write!(f, "\n_Command cancelled_")
} }
RequestCommandOutputResult::ExecutionError { command, message } => {
write!(f, "\n_Command `{command}` was not executed: {message}_")
}
RequestCommandOutputResult::Denylisted { command } => { RequestCommandOutputResult::Denylisted { command } => {
write!( write!(
f, f,
@@ -3225,9 +3240,19 @@ impl AIAgentExchange {
pub fn format_output_for_copy( pub fn format_output_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_output_for_copy_for_conversation(action_model, None)
}
pub fn format_output_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
match self.output_status.output() { match self.output_status.output() {
Some(output) => output.get().format_for_copy(action_model), Some(output) => output
.get()
.format_for_copy_for_conversation(action_model, conversation_id),
None => String::new(), None => String::new(),
} }
} }
@@ -3238,9 +3263,18 @@ impl AIAgentExchange {
pub fn format_for_copy( pub fn format_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
let input_text = self.format_input_for_copy(); let input_text = self.format_input_for_copy();
let output_text = self.format_output_for_copy(action_model); let output_text =
self.format_output_for_copy_for_conversation(action_model, conversation_id);
let has_user_input = !input_text.is_empty(); let has_user_input = !input_text.is_empty();
let has_agent_output = !output_text.is_empty(); let has_agent_output = !output_text.is_empty();
+8
View File
@@ -61,6 +61,9 @@ pub mod text {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
writeln!(w, "{CANCELLED_MESSAGE}") writeln!(w, "{CANCELLED_MESSAGE}")
} }
RequestCommandOutputResult::ExecutionError { command, message } => {
writeln!(w, "Command `{command}` was not executed: {message}")
}
RequestCommandOutputResult::Denylisted { .. } => { RequestCommandOutputResult::Denylisted { .. } => {
writeln!( writeln!(
w, w,
@@ -829,6 +832,11 @@ pub mod json {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
Some(JsonMessage::ToolCanceled) Some(JsonMessage::ToolCanceled)
} }
RequestCommandOutputResult::ExecutionError { message, .. } => {
Some(JsonMessage::ToolError {
error: Cow::Borrowed(message),
})
}
RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError { RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError {
error: Cow::Borrowed( error: Cow::Borrowed(
"Command was not allowed to run due to presence on denylist", "Command was not allowed to run due to presence on denylist",
+178 -110
View File
@@ -31,7 +31,7 @@ pub use execute::{
ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent,
RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor,
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, StartAgentWaitPolicy,
}; };
use futures::future::{join_all, BoxFuture}; use futures::future::{join_all, BoxFuture};
use galaxy_agent_core::{ 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)); 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 { fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
let status = if permission_denied { let status = if permission_denied {
ToolResultStatus::Denied ToolResultStatus::Denied
@@ -587,6 +626,9 @@ fn action_result_failure_summary(result: &AIAgentActionResultType) -> Option<Str
AIAgentActionResultType::RunAgents(RunAgentsResult::Failure { error }) => { AIAgentActionResultType::RunAgents(RunAgentsResult::Failure { error }) => {
Some(error.clone()) Some(error.clone())
} }
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { message, .. },
) => Some(message.clone()),
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { .. } RequestCommandOutputResult::Completed { .. }
| RequestCommandOutputResult::CancelledBeforeExecution | RequestCommandOutputResult::CancelledBeforeExecution
@@ -734,7 +776,7 @@ pub struct BlocklistAIActionModel {
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>, HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
/// Past actions and their corresponding statuses from previous AI exchanges. /// 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. /// The ID of the terminal view this controller is associated with.
terminal_view_id: EntityId, terminal_view_id: EntityId,
@@ -774,6 +816,7 @@ impl BlocklistAIActionModel {
let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id); let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id);
ctx.emit(BlocklistAIActionEvent::ExecutingAction { ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id: *conversation_id,
execution_ref: execution_ref.clone(), execution_ref: execution_ref.clone(),
}); });
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
@@ -863,6 +906,7 @@ impl BlocklistAIActionModel {
); );
ctx.emit(BlocklistAIActionEvent::ExecutingAction { ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id,
execution_ref: self.provider_tool_execution_ref(conversation_id, action_id), execution_ref: self.provider_tool_execution_ref(conversation_id, action_id),
}); });
} }
@@ -988,8 +1032,8 @@ impl BlocklistAIActionModel {
.get(&conversation_id) .get(&conversation_id)
.map(|q| q.len()) .map(|q| q.len())
.unwrap_or(0); .unwrap_or(0);
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_available_actions: conversation={:?}, pending_count={}", "try_to_execute_available_actions: conversation={:?}, pending_count={}",
conversation_id, conversation_id,
pending_count pending_count
); );
@@ -1000,14 +1044,14 @@ impl BlocklistAIActionModel {
.and_then(|queue| queue.front()) .and_then(|queue| queue.front())
.cloned() .cloned()
else { else {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_available_actions: no more pending actions" "try_to_execute_available_actions: no more pending actions"
); );
return; return;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_available_actions: trying action id={:?}, type={:?}", "try_to_execute_available_actions: trying action id={:?}, type={:?}",
front_action.id, front_action.id,
std::mem::discriminant(&front_action.action) std::mem::discriminant(&front_action.action)
); );
@@ -1019,8 +1063,8 @@ impl BlocklistAIActionModel {
current_phase, current_phase,
ctx, ctx,
) { ) {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_available_actions: cannot start in current phase {:?}", "try_to_execute_available_actions: cannot start in current phase {:?}",
current_phase current_phase
); );
return; return;
@@ -1033,12 +1077,12 @@ impl BlocklistAIActionModel {
ActionExecutionInitiator::Automatic, ActionExecutionInitiator::Automatic,
ctx, ctx,
) else { ) 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; return;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_available_actions: action started, result={:?}", "try_to_execute_available_actions: action started, result={:?}",
std::mem::discriminant(&result) std::mem::discriminant(&result)
); );
@@ -1048,7 +1092,9 @@ impl BlocklistAIActionModel {
phase: RunningActionPhase::Serial 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; 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. /// Returns all pending actions for a specific conversation.
pub fn get_pending_actions_for_conversation( pub fn get_pending_actions_for_conversation(
&self, &self,
@@ -1106,11 +1144,15 @@ impl BlocklistAIActionModel {
self.blocked_action_for_conversation(&conversation_id) self.blocked_action_for_conversation(&conversation_id)
} }
/// Returns a pending action by its ID, searching across all conversations. /// Returns a pending action by its ID within the given conversation.
pub fn get_pending_action_by_id(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> { pub fn get_pending_action_by_id(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.pending_actions self.pending_actions
.values() .get(&conversation_id)?
.flat_map(|queue| queue.iter()) .iter()
.find(|action| &action.id == action_id) .find(|action| &action.id == action_id)
} }
@@ -1142,7 +1184,11 @@ impl BlocklistAIActionModel {
self.running_actions self.running_actions
.get(&conversation_id) .get(&conversation_id)
.and_then(RunningActions::first_action_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. /// 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) self.finished_action_results.get(&conversation_id)
} }
/// Returns the `AIActionStatus` for the action corresponding to the given `id`, if any. /// Returns the status for an action within the given conversation.
pub fn get_action_status(&self, id: &AIAgentActionId) -> Option<AIActionStatus> { pub fn get_action_status(
for (conversation_id, pending_actions_for_conversation) in &self.pending_actions { &self,
for (index, action) in pending_actions_for_conversation.iter().enumerate() { conversation_id: AIConversationId,
if &action.id != id { id: &AIAgentActionId,
continue; ) -> Option<AIActionStatus> {
} if let Some(status) = pending_action_status(
&self.pending_actions,
if index == 0 &self.running_actions,
&& !self.is_view_only conversation_id,
&& !self.running_actions.contains_key(conversation_id) id,
{ self.is_view_only,
return Some(AIActionStatus::Blocked); ) {
} return Some(status);
return Some(AIActionStatus::Queued);
}
} }
self.running_actions self.running_actions
.values() .get(&conversation_id)
.find(|running| running.contains(id)) .filter(|running| running.contains(id))
.map(|_| AIActionStatus::RunningAsync) .map(|_| AIActionStatus::RunningAsync)
.or_else(|| { .or_else(|| {
self.get_action_result(id) self.get_action_result(conversation_id, id)
.map(|result| AIActionStatus::Finished(result.clone())) .map(|result| AIActionStatus::Finished(result.clone()))
}) })
.or_else(|| { .or_else(|| {
self.pending_preprocessed_actions self.pending_preprocessed_actions
.values() .get(&conversation_id)
.any(|preprocessing| preprocessing.contains(id)) .is_some_and(|preprocessing| preprocessing.contains(id))
.then_some(AIActionStatus::Preprocessing) .then_some(AIActionStatus::Preprocessing)
}) })
} }
pub fn get_action_result(&self, id: &AIAgentActionId) -> Option<&Arc<AIAgentActionResult>> { pub fn get_action_result(
// Search through all conversations' finished action results &self,
self.finished_action_results conversation_id: AIConversationId,
.values() id: &AIAgentActionId,
.chain(self.provider_finished_action_results.values()) ) -> Option<&Arc<AIAgentActionResult>> {
.flat_map(|results| results.iter()) action_result_for_conversation(
.find(|result| &result.id == id) &self.finished_action_results,
.or_else(|| self.past_action_results.get(id)) &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) /// 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 exchange in exchanges.iter() {
for input in &exchange.input { for input in &exchange.input {
if let AIAgentInput::ActionResult { result, .. } = input { if let AIAgentInput::ActionResult { result, .. } = input {
@@ -1257,7 +1308,7 @@ impl BlocklistAIActionModel {
); );
} }
self.past_action_results 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. /// from the confirmation card.
pub fn execute_run_agents( pub fn execute_run_agents(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
request: ai::agent::action::RunAgentsRequest, request: ai::agent::action::RunAgentsRequest,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let mut found = None; let Some(action) = self
for (conv_id, queue) in self.pending_actions.iter_mut() { .pending_actions
if let Some(action) = queue.iter_mut().find(|action| &action.id == action_id) { .get_mut(&conversation_id)
found = Some((*conv_id, action)); .and_then(|queue| queue.iter_mut().find(|action| &action.id == action_id))
break; else {
}
}
let Some((conversation_id, action)) = found else {
log::warn!( log::warn!(
"BlocklistAIActionModel::execute_run_agents: no pending action for {action_id:?}" "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. /// the time the action becomes blocked on user confirmation.
pub fn deny_run_agents( pub fn deny_run_agents(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
reason: String, reason: String,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let mut found: Option<(AIConversationId, AIAgentAction)> = None; let Some(action) = self
for (conv_id, queue) in self.pending_actions.iter_mut() { .pending_actions
if let Some(idx) = queue.iter().position(|a| &a.id == action_id) { .get_mut(&conversation_id)
if let Some(action) = queue.remove(idx) { .and_then(|queue| {
found = Some((*conv_id, action)); let index = queue.iter().position(|action| &action.id == action_id)?;
} queue.remove(index)
break; })
} else {
}
let Some((conversation_id, action)) = found else {
log::warn!( log::warn!(
"BlocklistAIActionModel::deny_run_agents: no pending action for {action_id:?}" "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); let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: action.id.clone(), action_id: action.id.clone(),
conversation_id,
execution_ref: execution_ref.clone(), execution_ref: execution_ref.clone(),
}); });
ctx.emit(BlocklistAIActionEvent::ToolLifecycle { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
@@ -1698,14 +1747,14 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId, conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] queue_actions: queuing {} actions for conversation {:?}", "queue_actions: queuing {} actions for conversation {:?}",
actions.len(), actions.len(),
conversation_id conversation_id
); );
for (i, action) in actions.iter().enumerate() { for (i, action) in actions.iter().enumerate() {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] queue_actions: [{}] id={:?}, type={:?}", "queue_actions: [{}] id={:?}, type={:?}",
i, i,
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
@@ -1856,7 +1905,7 @@ impl BlocklistAIActionModel {
reason: CancellationReason, reason: CancellationReason,
ctx: &mut ModelContext<Self>, 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()); let permission_denied = is_permission_denial(reason, status.as_ref());
if self if self
.running_actions .running_actions
@@ -1864,7 +1913,7 @@ impl BlocklistAIActionModel {
.is_some_and(|running| running.contains(action_id)) .is_some_and(|running| running.contains(action_id))
{ {
self.executor.update(ctx, |executor, ctx| { 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 { } else {
let Some(pending_actions_for_conversation) = let Some(pending_actions_for_conversation) =
@@ -1933,12 +1982,14 @@ impl BlocklistAIActionModel {
}; };
for action in actions_to_cancel.drain(..).collect_vec() { for action in actions_to_cancel.drain(..).collect_vec() {
log::info!( 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), AIAgentActionTypeDiscriminants::from(&action.action),
action.id, action.id,
reason, reason
std::backtrace::Backtrace::force_capture()
); );
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); self.cancel_pending_action(conversation_id, action, reason, false, ctx);
} }
} }
@@ -2053,7 +2104,7 @@ impl BlocklistAIActionModel {
for result in finished_action_results.iter() { for result in finished_action_results.iter() {
self.past_action_results self.past_action_results
.insert(result.id.clone(), result.clone()); .insert((conversation_id, result.id.clone()), result.clone());
} }
finished_action_results finished_action_results
.into_iter() .into_iter()
@@ -2091,7 +2142,8 @@ impl BlocklistAIActionModel {
.remove(&(conversation_id, work_id.clone())) .remove(&(conversation_id, work_id.clone()))
.unwrap_or_default(); .unwrap_or_default();
for result in results { 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. /// respective functions.
pub fn handle_requested_command_accepted( pub fn handle_requested_command_accepted(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
command: String, command: String,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
// Search through all pending conversations to find the action and conversation ID let Some(action) = self
let mut found_conversation_id = None; .pending_actions
for (conversation_id, pending_actions_for_conversation) in self.pending_actions.iter_mut() { .get_mut(&conversation_id)
if let Some(action) = pending_actions_for_conversation .and_then(|actions| actions.iter_mut().find(|action| action.id == *action_id))
.iter_mut() else {
.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 {
log::warn!("Ignoring acceptance for non-pending requested command: {action_id:?}"); log::warn!("Ignoring acceptance for non-pending requested command: {action_id:?}");
return; 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); self.execute_action(action_id, conversation_id, ctx);
} }
@@ -2161,8 +2208,8 @@ impl BlocklistAIActionModel {
cancellation_reason: Option<CancellationReason>, cancellation_reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] handle_action_result: action_id={:?}, result_type={:?}, cancellation={:?}", "handle_action_result: action_id={:?}, result_type={:?}, cancellation={:?}",
action_result.id, action_result.id,
std::mem::discriminant(&action_result.result), std::mem::discriminant(&action_result.result),
cancellation_reason cancellation_reason
@@ -2453,11 +2500,13 @@ pub enum BlocklistAIActionEvent {
/// Emitted when the action with the given ID requires user confirmation to execute. /// Emitted when the action with the given ID requires user confirmation to execute.
ActionBlockedOnUserConfirmation { ActionBlockedOnUserConfirmation {
action_id: AIAgentActionId, action_id: AIAgentActionId,
conversation_id: AIConversationId,
execution_ref: Option<ProviderToolExecutionRef>, execution_ref: Option<ProviderToolExecutionRef>,
}, },
/// Emitted when the action with the given ID begins execution. /// Emitted when the action with the given ID begins execution.
ExecutingAction { ExecutingAction {
action_id: AIAgentActionId, action_id: AIAgentActionId,
conversation_id: AIConversationId,
execution_ref: Option<ProviderToolExecutionRef>, execution_ref: Option<ProviderToolExecutionRef>,
}, },
/// Emitted when the action with the given ID has finished. /// Emitted when the action with the given ID has finished.
@@ -2496,6 +2545,25 @@ impl BlocklistAIActionEvent {
BlocklistAIActionEvent::InsertCodeReviewComments { action_id, .. } => action_id, 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 { impl Entity for BlocklistAIActionModel {
+215 -51
View File
@@ -74,6 +74,7 @@ use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{ pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentWaitPolicy,
}; };
pub use suggest_new_conversation::NewConversationDecision; pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor; use suggest_new_conversation::SuggestNewConversationExecutor;
@@ -245,9 +246,36 @@ pub(super) enum TryExecuteResult {
#[derive(Clone)] #[derive(Clone)]
struct AsyncExecutingAction { struct AsyncExecutingAction {
action: AIAgentAction, 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 { impl AsyncExecutingAction {
@@ -286,10 +314,8 @@ pub struct BlocklistAIActionExecutor {
send_message_executor: ModelHandle<SendMessageToAgentExecutor>, send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>, ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>, wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID. /// The actions currently executing asynchronously, scoped by conversation and action ID.
/// We track them per action rather than as a single slot so multiple actions from the same async_executing_actions: AsyncExecutingActions,
/// parallel phase can complete independently.
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
restored_action_ids: HashSet<AIAgentActionId>, restored_action_ids: HashSet<AIAgentActionId>,
/// Reference to the terminal model for checking session sharing state. /// 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 self.async_executing_actions
.get(action_id) .get(conversation_id, action_id)
.map(|running| &running.action) .map(|running| &running.action)
} }
@@ -408,13 +438,16 @@ impl BlocklistAIActionExecutor {
} }
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool { pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
self.async_executing_actions.values().any(|running| { self.async_executing_actions
running.conversation_id == conversation_id .0
&& matches!( .iter()
running.action.action, .any(|((running_conversation_id, _), running)| {
AIAgentActionType::AskUserQuestion { .. } *running_conversation_id == conversation_id
) && matches!(
}) running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
} }
/// Returns the action_id of any running WaitForEvents action for the /// Returns the action_id of any running WaitForEvents action for the
@@ -424,10 +457,9 @@ impl BlocklistAIActionExecutor {
&self, &self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
) -> Option<AIAgentActionId> { ) -> Option<AIAgentActionId> {
self.async_executing_actions self.async_executing_actions.0.iter().find_map(
.iter() |((running_conversation_id, action_id), running)| {
.find_map(|(action_id, running)| { if *running_conversation_id == conversation_id
if running.conversation_id == conversation_id
&& matches!( && matches!(
running.action.action, running.action.action,
AIAgentActionType::WaitForEvents { .. } AIAgentActionType::WaitForEvents { .. }
@@ -437,7 +469,8 @@ impl BlocklistAIActionExecutor {
} else { } else {
None None
} }
}) },
)
} }
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> { pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
@@ -642,8 +675,8 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool, is_user_initiated: bool,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> TryExecuteResult { ) -> TryExecuteResult {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}", "try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id, action.id,
std::mem::discriminant(&action.action), std::mem::discriminant(&action.action),
is_user_initiated is_user_initiated
@@ -651,7 +684,9 @@ impl BlocklistAIActionExecutor {
// We should never actually execute actions in view-only mode. // We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() { 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 { return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer, reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action), action: Box::new(action),
@@ -664,8 +699,8 @@ impl BlocklistAIActionExecutor {
}; };
let can_auto_execute = self.should_autoexecute(input, ctx); let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous(); let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}", "try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute, can_auto_execute,
is_agent_autonomous is_agent_autonomous
); );
@@ -677,8 +712,8 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute || can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output())); || (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation { if needs_confirmation {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}", "try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id action.id
); );
return TryExecuteResult::NotExecuted { return TryExecuteResult::NotExecuted {
@@ -713,8 +748,8 @@ impl BlocklistAIActionExecutor {
} }
} }
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}", "try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
); );
@@ -870,8 +905,8 @@ impl BlocklistAIActionExecutor {
}; };
let action_id = action_clone.id.clone(); let action_id = action_clone.id.clone();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}", "try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution { match &execution {
AnyActionExecution::NotReady => "NotReady", AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction", AnyActionExecution::InvalidAction => "InvalidAction",
@@ -882,8 +917,8 @@ impl BlocklistAIActionExecutor {
); );
match execution { match execution {
AnyActionExecution::NotReady => { AnyActionExecution::NotReady => {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}", "try_to_execute_action: NOT READY - action_id={:?}",
action_id action_id
); );
TryExecuteResult::NotExecuted { TryExecuteResult::NotExecuted {
@@ -893,7 +928,7 @@ impl BlocklistAIActionExecutor {
} }
AnyActionExecution::InvalidAction => { AnyActionExecution::InvalidAction => {
log::error!( log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}", "try_to_execute_action: invalid action, action_id={:?}",
action_id action_id
); );
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor."); debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
@@ -907,10 +942,9 @@ impl BlocklistAIActionExecutor {
on_complete, on_complete,
} => { } => {
self.async_executing_actions.insert( self.async_executing_actions.insert(
action_id.clone(), conversation_id,
AsyncExecutingAction { AsyncExecutingAction {
action: action_clone, action: action_clone,
conversation_id,
}, },
); );
if !is_restored { if !is_restored {
@@ -919,15 +953,21 @@ impl BlocklistAIActionExecutor {
conversation_id, 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| { ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else { let Some(running) = me
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id); .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; return;
}; };
let result = on_complete(result, ctx); let result = on_complete(result, ctx);
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}", "try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id, action_id,
std::mem::discriminant(&result) std::mem::discriminant(&result)
); );
@@ -937,7 +977,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id, task_id: running.action.task_id,
result, result,
}), }),
conversation_id: running.conversation_id, conversation_id,
cancellation_reason: None, cancellation_reason: None,
}); });
}); });
@@ -981,6 +1021,7 @@ impl BlocklistAIActionExecutor {
pub fn cancel_running_async_action( pub fn cancel_running_async_action(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
reason: Option<CancellationReason>, reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
@@ -989,13 +1030,42 @@ impl BlocklistAIActionExecutor {
if self.is_shared_session_viewer() { if self.is_shared_session_viewer() {
return; 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); let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!( log::info!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}", "Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
std::backtrace::Backtrace::force_capture()
); );
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| { self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx); executor.cancel_execution(&running.action.id, ctx);
}); });
@@ -1007,6 +1077,10 @@ impl BlocklistAIActionExecutor {
self.run_agents_executor.update(ctx, |executor, ctx| { self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, 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, .. } = } else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action &running.action.action
{ {
@@ -1023,7 +1097,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id, task_id: running.action.task_id,
result: running.action.action.cancelled_result(), result: running.action.action.cancelled_result(),
}), }),
conversation_id: running.conversation_id, conversation_id,
cancellation_reason: reason, cancellation_reason: reason,
}); });
} }
@@ -1037,13 +1111,14 @@ impl BlocklistAIActionExecutor {
) { ) {
let action_ids = self let action_ids = self
.async_executing_actions .async_executing_actions
.0
.iter() .iter()
.filter_map(|(action_id, running)| { .filter_map(|((running_conversation_id, action_id), _)| {
(running.conversation_id == conversation_id).then_some(action_id.clone()) (*running_conversation_id == conversation_id).then_some(action_id.clone())
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for action_id in action_ids { 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) 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"))] #[cfg(all(test, feature = "local_fs"))]
#[path = "execute_tests.rs"] #[path = "execute_tests.rs"]
mod tests; mod tests;
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))] #[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 server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction { let AIAgentAction {
action: action:
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
.. ..
} = input.action } = input.action
else { else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!"); log::error!("CallMCPToolExecutor::execute: action type mismatch");
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
let name_owned = name.to_owned(); let name_owned = name.to_owned();
let name_clone = name_owned.clone(); let name_clone = name_owned.clone();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}", "CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name, name,
server_id, server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string()) serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
); );
let serde_json::Value::Object(mut arguments) = input.clone() else { 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( return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()), CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
)); ));
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
let Some(reconnecting_peer) = templatable_peer else { let Some(reconnecting_peer) = templatable_peer else {
log::error!( log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND", "CallMCPToolExecutor: MCP server for tool '{}' not found",
name_owned name_owned
); );
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool( return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()), CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
)); ));
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'", "CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned name_owned
); );
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
tool_name: String, tool_name: String,
ctx: &galaxyui::AppContext, ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType { ) -> AIAgentActionResultType {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}", "handle_call_tool_result: tool_name={}, is_ok={}",
tool_name, tool_name,
res.is_ok() res.is_ok()
); );
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}", "FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns, patterns,
path path
); );
@@ -237,8 +237,8 @@ impl GrepExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}", "GrepExecutor::execute: queries={:?}, path={:?}",
queries, queries,
path path
); );
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested", "ReadFilesExecutor::execute: {} files requested",
locations.len() locations.len()
); );
@@ -173,14 +173,14 @@ impl RequestFileEditsExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}", "RequestFileEditsExecutor::execute: action_id={:?}",
id id
); );
let Some(diff_view) = self.diff_views.get(id) else { let Some(diff_view) = self.diff_views.get(id) else {
log::warn!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}", "RequestFileEditsExecutor: no diff view found for action_id={:?}",
id id
); );
return ActionExecution::NotReady; return ActionExecution::NotReady;
@@ -21,7 +21,7 @@ use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{ use super::start_agent::{
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
StartAgentWaitPolicy, StartAgentRequestId, StartAgentWaitPolicy,
}; };
use super::{ use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput, child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
@@ -146,9 +146,12 @@ impl RunAgentsExecutor {
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
self.recovery_action_ids.remove(action_id); self.recovery_action_ids.remove(action_id);
self.start_agent_executor.update(ctx, |executor, _| { let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_dispatches_for_action(action_id); 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() { if self.pending.remove(action_id).is_some() {
ctx.emit(RunAgentsExecutorEvent::SpawningFinished { ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
action_id: action_id.clone(), action_id: action_id.clone(),
@@ -163,6 +166,22 @@ impl RunAgentsExecutor {
) { ) {
for agent in agents { for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else { 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; continue;
}; };
let Some(normalized_name) = normalize_agent_name(&agent.name) else { 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 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 { for cfg in &agent_run_configs {
let normalized_name = normalize_agent_name(&cfg.name) let normalized_name = normalize_agent_name(&cfg.name)
.expect("validated RunAgents requests have non-empty agent names"); .expect("validated RunAgents requests have non-empty agent names");
@@ -382,7 +405,7 @@ impl RunAgentsExecutor {
cfg.name.clone(), cfg.name.clone(),
parent_conversation_id, parent_conversation_id,
child_conversation_id, child_conversation_id,
parent_run_id.clone(), wait_policy,
exec_ctx, exec_ctx,
) )
}); });
@@ -485,9 +508,9 @@ impl RunAgentsExecutor {
ctx.spawn( ctx.spawn(
async move { 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"))] #[cfg(not(target_family = "wasm"))]
for (slot_index, kind) in outcomes.iter().enumerate() { for (slot_index, resolved) in resolved_slots.iter().enumerate() {
log::info!( log::info!(
"RunAgents child launch outcome action_id={} parent_conversation_id={} \ "RunAgents child launch outcome action_id={} parent_conversation_id={} \
agent_name={} slot_index={} outcome={}", agent_name={} slot_index={} outcome={}",
@@ -498,21 +521,32 @@ impl RunAgentsExecutor {
.map(String::as_str) .map(String::as_str)
.unwrap_or("<unknown>"), .unwrap_or("<unknown>"),
slot_index, slot_index,
run_agents_agent_outcome_kind_label(kind) run_agents_agent_outcome_kind_label(&resolved.outcome)
); );
} }
outcomes resolved_slots
}, },
move |me, outcomes, ctx| { move |me, resolved_slots, ctx| {
if !me.is_pending(&action_id_for_aggr) { if !me.is_pending(&action_id_for_aggr) {
return; 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 let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter() .iter()
.zip(outcomes) .zip(resolved_slots)
.map(|(cfg, kind)| RunAgentsAgentOutcome { .map(|(cfg, resolved)| RunAgentsAgentOutcome {
name: cfg.name.clone(), name: cfg.name.clone(),
kind, kind: resolved.outcome,
}) })
.collect(); .collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents); me.record_launched_agents(parent_conversation_id_for_result, &agents);
@@ -526,7 +560,7 @@ impl RunAgentsExecutor {
"action_id": action_id_for_aggr.to_string(), "action_id": action_id_for_aggr.to_string(),
"parent_conversation_id": parent_conversation_id_for_result.to_string(), "parent_conversation_id": parent_conversation_id_for_result.to_string(),
"agent_count": agents.len(), "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(), "failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
"agents": agents "agents": agents
.iter() .iter()
@@ -536,6 +570,12 @@ impl RunAgentsExecutor {
"status": "launched", "status": "launched",
"agent_id": agent_id.as_str(), "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!({ RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name.as_str(), "name": agent.name.as_str(),
"status": "failed", "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 { fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
match kind { match kind {
RunAgentsAgentOutcomeKind::Launched { .. } => "launched", RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
RunAgentsAgentOutcomeKind::Failed { .. } => "failed", RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
} }
} }
@@ -732,18 +773,30 @@ enum ChildSlot {
Pending(StartAgentDispatch), 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 resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
} }
async fn resolve_child_slot_with_timeout( async fn resolve_child_slot_with_timeout(
slot: ChildSlot, slot: ChildSlot,
spawn_timeout: Duration, spawn_timeout: Duration,
) -> RunAgentsAgentOutcomeKind { ) -> ResolvedChildSlot {
let dispatch = match slot { 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, ChildSlot::Pending(dispatch) => dispatch,
}; };
let request_id = dispatch.request_id;
let outcome = match dispatch.wait_policy { let outcome = match dispatch.wait_policy {
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(), 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::Left((outcome, _)) => outcome.ok(),
futures::future::Either::Right((_, _)) => { futures::future::Either::Right((_, _)) => {
dispatch.mark_detached();
log::warn!( log::warn!(
"Agent spawn timed out after {} seconds", "Agent spawn timed out after {} seconds",
spawn_timeout.as_secs() spawn_timeout.as_secs()
); );
return RunAgentsAgentOutcomeKind::Failed { return ResolvedChildSlot {
error: format!( outcome: RunAgentsAgentOutcomeKind::Failed {
"Agent failed to start within {} seconds. \ error: format!(
The harness binary may not be installed.", "Agent failed to start within {} seconds. \
spawn_timeout.as_secs() The harness binary may not be installed.",
), spawn_timeout.as_secs()
),
},
timed_out_request_id: Some(request_id),
}; };
} }
} }
} }
}; };
match outcome { let outcome = match outcome {
Some(StartAgentOutcome::Started { agent_id }) Some(StartAgentOutcome::Started { agent_id }) => {
| Some(StartAgentOutcome::Completed { agent_id, .. }) => {
RunAgentsAgentOutcomeKind::Launched { agent_id } RunAgentsAgentOutcomeKind::Launched { agent_id }
} }
Some(StartAgentOutcome::Completed { agent_id, output }) => {
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
}
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error }, Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
None => RunAgentsAgentOutcomeKind::Failed { None => RunAgentsAgentOutcomeKind::Failed {
error: "Child agent was cancelled before completion".to_string(), 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 { for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else { 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; continue;
}; };
let Some(normalized_name) = normalize_agent_name(&agent.name) else { let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -1,4 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{ 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 (second_sender, second_receiver) = async_channel::bounded(1);
let slots = vec![ let slots = vec![
ChildSlot::Pending(StartAgentDispatch { ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver: first_receiver, receiver: first_receiver,
wait_policy: StartAgentWaitPolicy::Completion, wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}), }),
ChildSlot::Pending(StartAgentDispatch { ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(2),
receiver: second_receiver, receiver: second_receiver,
wait_policy: StartAgentWaitPolicy::Completion, wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}), }),
ChildSlot::Failed("prelaunch failure".to_string()), ChildSlot::Failed("prelaunch failure".to_string()),
]; ];
@@ -557,15 +563,16 @@ fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
let outcomes = outcomes.await; let outcomes = outcomes.await;
assert!(matches!( assert!(matches!(
&outcomes[0], &outcomes[0].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed" RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
)); ));
assert!(matches!( assert!(matches!(
&outcomes[1], &outcomes[1].outcome,
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "second-agent" RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "second-agent" && output == "done"
)); ));
assert!(matches!( assert!(matches!(
&outcomes[2], &outcomes[2].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure" 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 (sender, receiver) = async_channel::bounded(1);
let completion = Box::pin(resolve_child_slot_with_timeout( let completion = Box::pin(resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch { ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver, receiver,
wait_policy: StartAgentWaitPolicy::Completion, wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}), }),
Duration::from_millis(1), Duration::from_millis(1),
)); ));
@@ -598,8 +607,9 @@ fn completion_wait_ignores_spawn_timeout() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
completion.await, completion.await.outcome,
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "child-agent" 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 (_sender, receiver) = async_channel::bounded(1);
let outcome = resolve_child_slot_with_timeout( let outcome = resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch { ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver, receiver,
wait_policy: StartAgentWaitPolicy::Startup, wait_policy: StartAgentWaitPolicy::Startup,
detached: Arc::new(AtomicBool::new(false)),
}), }),
Duration::from_millis(1), Duration::from_millis(1),
) )
.await; .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!( assert!(matches!(
outcome, outcome.outcome,
RunAgentsAgentOutcomeKind::Failed { error } RunAgentsAgentOutcomeKind::Failed { error }
if error.contains("Agent failed to start within") 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 { fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false); initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app); let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_util::path::ShellFamily; use galaxy_util::path::ShellFamily;
use galaxyui::r#async::{Spawnable, Timer}; use galaxyui::r#async::{Spawnable, Timer};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex; use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput}; use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor { pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>, 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 /// 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, /// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout. /// 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_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser. /// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
event: &ModelEvent, event: &ModelEvent,
_ctx: &mut ModelContext<Self>, _ctx: &mut ModelContext<Self>,
) { ) {
// We wait for precmd for the block _after_ the requested command's block so that // Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
// downstream checks for current working directory are fresh. The precmd hook is when // evidence for shells that never deliver a subsequent precmd.
// the shell relays current working directory to warp. if matches!(
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event { event,
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
| ModelEvent::BlockCompleted(_)
) {
let model = self.terminal_model.lock(); let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec(); let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() { for (block_selector, block_finished_txs) in block_finished_senders {
if let Some(block) = block_selector.get_block(&model) { let completed_block = block_selector.get_block(&model).filter(|block| {
if block.is_command_finished() { 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(()) { if let Err(e) = block_finished_tx.send(()) {
log::warn!( log::warn!(
"Failed to notify block completion for running requested command: {e:?}" "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, input: ExecuteActionInput,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> { ) -> impl Into<AnyActionExecution> {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}", "ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action) std::mem::discriminant(&input.action.action)
); );
let model = self.terminal_model.lock(); let model = self.terminal_model.lock();
@@ -204,12 +218,6 @@ impl ShellCommandExecutor {
// Determine the action we want to take based on the input. // Determine the action we want to take based on the input.
let action_id = input.action.id.clone(); let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle(); let handle = ctx.handle();
match &input.action.action { match &input.action.action {
AIAgentActionType::RequestCommandOutput { AIAgentActionType::RequestCommandOutput {
@@ -222,18 +230,13 @@ impl ShellCommandExecutor {
.active_block() .active_block()
.is_active_and_long_running() .is_active_and_long_running()
{ {
// Another command is still running (e.g. stuck in a pager). Return an error let running_command = model
// result so the model receives feedback and can adapt. Using Completed with a .block_list()
// non-zero exit code ensures a follow-up request is triggered. .active_block()
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput( .command_with_secrets_unobfuscated(false);
RequestCommandOutputResult::Completed { return ActionExecution::Sync(terminal_busy_execution_error(
command: command.clone(), command,
block_id: model.block_list().active_block().id().clone(), &running_command,
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,
},
)); ));
} }
// If another conversation has taken over the agent view since this 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. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -339,8 +341,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -371,6 +372,7 @@ impl ShellCommandExecutor {
}, },
)); ));
} }
let command = block.command_with_secrets_unobfuscated(false);
drop(model); drop(model);
let block_selector = BlockSelector::Id(block_id.clone()); let block_selector = BlockSelector::Id(block_id.clone());
@@ -380,8 +382,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -419,7 +420,9 @@ impl ShellCommandExecutor {
// Set up a future to also wait for block completion. // Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel(); let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders 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. // Build the future that captures terminal model and block data.
let transfer_future = { let transfer_future = {
@@ -491,7 +494,7 @@ impl ShellCommandExecutor {
// Clean up. // Clean up.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.control_handback_sender = None; me.control_handback_sender = None;
}); });
} }
@@ -520,13 +523,17 @@ impl ShellCommandExecutor {
// Create a channel to notify us when we receive block metadata. // Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel(); let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders 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 // Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately. // the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel(); let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders 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. // Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone(); let terminal_model = self.terminal_model.clone();
@@ -600,7 +607,12 @@ impl ShellCommandExecutor {
completed_ts: block.completed_ts().cloned(), completed_ts: block.completed_ts().cloned(),
} }
} else { } 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( formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(), model.alt_screen().grid_handler(),
None, None,
@@ -618,7 +630,7 @@ impl ShellCommandExecutor {
block_id: block.id().clone(), block_id: block.id().clone(),
grid_contents, grid_contents,
cursor: CURSOR_MARKER, cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(), is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted, 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 terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block(); let requested_selector = BlockSelector::RequestedCommandId(id.clone());
if !active_block.is_active_and_long_running() { let requested_block_is_running = requested_selector
return; .get_block(&terminal_model)
} .is_some_and(|block| block.is_active_and_long_running() && !block.finished());
let selector = if requested_block_is_running {
let selector = if active_block requested_selector
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
} else { } else {
BlockSelector::Id(active_block.id().clone()) BlockSelector::Id(terminal_model.active_block_id().clone())
}; };
self.block_finished_senders.remove(&selector); // Cancelling the wait future alone would report cancellation while the process keeps
self.force_refresh_senders.remove(&selector); // 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 /// 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. /// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool { pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at // Find every pending poll whose selector resolves to this block. Multiple provider polls
// most one: a given block can have at most one in-flight `action_result_future` // may legitimately wait on the same command and must be refreshed together.
// at a time.
let matching_selector = self let matching_selector = self
.force_refresh_senders .force_refresh_senders
.keys() .keys()
@@ -674,8 +712,12 @@ impl ShellCommandExecutor {
drop(terminal_model); drop(terminal_model);
if let Some(selector) = matching_selector { if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) { if let Some(senders) = self.force_refresh_senders.remove(&selector) {
return sender.send(()).is_ok(); let mut refreshed = false;
for sender in senders {
refreshed |= sender.send(()).is_ok();
}
return refreshed;
} }
} }
false 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)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector { enum BlockSelector {
Id(BlockId), Id(BlockId),
@@ -919,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
input: Bytes, input: Bytes,
mode: AIAgentPtyWriteMode, mode: AIAgentPtyWriteMode,
}, },
CancelExecution, CancelExecution {
action_id: AIAgentActionId,
},
/// Emitted when the agent requests to transfer control of a long-running command to the user. /// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser { TransferControlToUser {
action_id: AIAgentActionId, action_id: AIAgentActionId,
@@ -1,19 +1,29 @@
use std::sync::Arc; use std::sync::Arc;
use std::task::Poll;
use async_channel::unbounded; use async_channel::unbounded;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::{pin_mut, poll};
use parking_lot::FairMutex; use parking_lot::FairMutex;
use warpui::{App, EntityId}; use warpui::{App, EntityId};
use super::{command_for_execution, ActionResult, BlockSelector, ShellCommandExecutor}; use super::{
use crate::ai::agent::ShellCommandDelay; command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent}; 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::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions; use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel}; use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType; use crate::terminal::shell::ShellType;
use crate::AIConversationId;
#[test] #[test]
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() { 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 /// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to /// detector reacts only to `BlockMetadataReceived` (precmd) and not to
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on /// `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 selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>(); let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| { executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx); executor.block_finished_senders.insert(selector, vec![tx]);
}); });
assert_eq!( assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()), 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)" that map is reserved for precmd (BlockMetadataReceived)"
); );
// Precmd event — the senders map should be drained (and since the // An unrelated precmd cannot resolve this selector, so its waiter must survive.
// block isn't in the terminal model, the sender is dropped).
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| { model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived( ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent { BlockMetadataReceivedEvent {
@@ -102,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
}); });
assert_eq!( assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()), app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0, 1,
"BlockMetadataReceived should drain the finish senders" "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.update(&mut app, |executor, _| {
executor executor
.force_refresh_senders .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));
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.update(&mut app, |executor, _| {
executor executor
.force_refresh_senders .force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx); .insert(BlockSelector::Id(block_id.clone()), vec![tx]);
}); });
terminal_model.lock().finish_block(); terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| { 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] #[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() { fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::FutureExt; use futures::FutureExt;
@@ -42,9 +44,24 @@ pub enum StartAgentWaitPolicy {
Completion, Completion,
} }
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
match mode {
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
}
}
pub struct StartAgentDispatch { pub struct StartAgentDispatch {
pub request_id: StartAgentRequestId,
pub receiver: async_channel::Receiver<StartAgentOutcome>, pub receiver: async_channel::Receiver<StartAgentOutcome>,
pub wait_policy: StartAgentWaitPolicy, 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 { fn invalid_local_child_harness_error(harness_type: &str) -> String {
@@ -139,6 +156,7 @@ struct PendingStartAgent {
/// Set once the child conversation is synchronously created. /// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>, child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>, sender: async_channel::Sender<StartAgentOutcome>,
detached: Arc<AtomicBool>,
/// Direct Bedrock/OpenAI parents do not have a server run id or an /// Direct Bedrock/OpenAI parents do not have a server run id or an
/// orchestration event stream. Keep the tool call open until their local /// orchestration event stream. Keep the tool call open until their local
/// child finishes, then return the child's output inline. /// child finishes, then return the child's output inline.
@@ -176,9 +194,17 @@ impl StartAgentExecutor {
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let child_link_event = { let child_link_event = {
let Some(pending) = self.pending.get_mut(&request_id) else { let Some(pending) = self.pending.get(&request_id) else {
return; 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); pending.child_conversation_id = Some(child_conversation_id);
if let Some(agent_name) = pending.run_agents_child_name.clone() { if let Some(agent_name) = pending.run_agents_child_name.clone() {
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated { Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
@@ -595,16 +621,13 @@ impl StartAgentExecutor {
} }
}; };
// In local mode (no parent_run_id), block until the child finishes // Local children return their completed work; remote children acknowledge startup and
// so the parent model receives the child's output as the tool result. // continue through the hosted orchestration lifecycle.
let wait_policy = if parent_run_id.is_none() { let wait_policy = wait_policy_for_execution_mode(&execution_mode);
StartAgentWaitPolicy::Completion
} else {
StartAgentWaitPolicy::Startup
};
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id(); let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert( self.pending.insert(
request_id, request_id,
PendingStartAgent { PendingStartAgent {
@@ -613,6 +636,7 @@ impl StartAgentExecutor {
parent_conversation_id, parent_conversation_id,
child_conversation_id: None, child_conversation_id: None,
sender, sender,
detached,
wait_policy, wait_policy,
}, },
); );
@@ -667,24 +691,23 @@ impl StartAgentExecutor {
parent_run_id: Option<String>, parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch { ) -> StartAgentDispatch {
let wait_policy = if parent_run_id.is_none() { let wait_policy = wait_policy_for_execution_mode(&execution_mode);
StartAgentWaitPolicy::Completion
} else {
StartAgentWaitPolicy::Startup
};
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) { if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error)); let _ = sender.try_send(StartAgentOutcome::Error(error));
return StartAgentDispatch { return StartAgentDispatch {
request_id,
receiver, receiver,
wait_policy, wait_policy,
detached,
}; };
} }
let (prompt, execution_mode) = let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode); normalize_legacy_local_child_harness_command(prompt, execution_mode);
let prompt = compose_leaf_agent_prompt(&prompt); let prompt = compose_leaf_agent_prompt(&prompt);
let request_id = self.next_request_id();
self.pending.insert( self.pending.insert(
request_id, request_id,
PendingStartAgent { PendingStartAgent {
@@ -693,6 +716,7 @@ impl StartAgentExecutor {
parent_conversation_id, parent_conversation_id,
child_conversation_id: None, child_conversation_id: None,
sender, sender,
detached: detached.clone(),
wait_policy, wait_policy,
}, },
); );
@@ -708,8 +732,10 @@ impl StartAgentExecutor {
}, },
))); )));
StartAgentDispatch { StartAgentDispatch {
request_id,
receiver, receiver,
wait_policy, wait_policy,
detached,
} }
} }
@@ -719,16 +745,12 @@ impl StartAgentExecutor {
name: String, name: String,
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId, child_conversation_id: AIConversationId,
parent_run_id: Option<String>, wait_policy: StartAgentWaitPolicy,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch { ) -> StartAgentDispatch {
let wait_policy = if parent_run_id.is_none() {
StartAgentWaitPolicy::Completion
} else {
StartAgentWaitPolicy::Startup
};
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id(); let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert( self.pending.insert(
request_id, request_id,
PendingStartAgent { PendingStartAgent {
@@ -737,19 +759,54 @@ impl StartAgentExecutor {
parent_conversation_id, parent_conversation_id,
child_conversation_id: Some(child_conversation_id), child_conversation_id: Some(child_conversation_id),
sender, sender,
detached: detached.clone(),
wait_policy, wait_policy,
}, },
); );
self.record_child_conversation(request_id, child_conversation_id, ctx); self.record_child_conversation(request_id, child_conversation_id, ctx);
StartAgentDispatch { StartAgentDispatch {
request_id,
receiver, receiver,
wait_policy, wait_policy,
detached,
} }
} }
pub fn cancel_dispatches_for_action(&mut self, action_id: &AIAgentActionId) { /// Detaches one exact dispatch. If its launch callback is already queued,
self.pending /// the shared marker prevents that callback from linking a late child.
.retain(|_, pending| &pending.action_id != action_id); 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( 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] #[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() { fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command( 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] #[test]
fn removing_direct_provider_child_resolves_pending_wait() { fn removing_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -975,7 +1103,7 @@ fn reattach_reuses_persisted_child_without_launching_another_agent() {
"child".to_string(), "child".to_string(),
parent_conversation_id, parent_conversation_id,
child_conversation_id, child_conversation_id,
None, StartAgentWaitPolicy::Completion,
ctx, ctx,
) )
}); });
+125
View File
@@ -346,3 +346,128 @@ fn only_rejecting_a_blocked_action_is_a_permission_denial() {
Some(&AIActionStatus::Blocked), 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 // 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) let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id) .conversation(&self.client_ids.conversation_id)
@@ -3673,6 +3676,7 @@ impl AIBlock {
RequestedCommandViewEvent::Accepted => { RequestedCommandViewEvent::Accepted => {
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted( action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
action_id, action_id,
view.as_ref(ctx).command_text().to_string(), view.as_ref(ctx).command_text().to_string(),
ctx, ctx,
@@ -3691,7 +3695,10 @@ impl AIBlock {
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => { RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
// We only care about expansion state updates when the command // We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block). // 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 has_finished_command_block = {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
terminal_model terminal_model
@@ -3890,7 +3897,7 @@ impl AIBlock {
if self if self
.action_model .action_model
.as_ref(ctx) .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()) .is_some_and(|status| status.is_blocked())
{ {
ctx.focus(&view); 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 // 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 // for restored conversations because action model events don't re-fire
// after the view is created. // 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) { if let Some(view) = self.search_codebase_view.get(action_id) {
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status { let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success { if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
@@ -4708,7 +4718,11 @@ impl AIBlock {
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool { pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
self.requested_action_ids self.requested_action_ids
.iter() .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()) .any(|status| status.is_blocked())
} }
@@ -4734,7 +4748,12 @@ impl AIBlock {
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| { ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
let action_id = event.action_id(); 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 // 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` // 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. // call, so early return here if this is errantly being called.
@@ -4828,7 +4847,7 @@ impl AIBlock {
{ {
let should_collapse = action_model let should_collapse = action_model
.as_ref(ctx) .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 { .is_none_or(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { exit_code, .. }, RequestCommandOutputResult::Completed { exit_code, .. },
@@ -4843,7 +4862,9 @@ impl AIBlock {
} }
if let Some(view) = me.search_codebase_view.get(action_id) { 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(ctx, |view, ctx| {
view.update_status(new_status); view.update_status(new_status);
ctx.notify(); ctx.notify();
@@ -4852,7 +4873,9 @@ impl AIBlock {
// Create subagent panel state for finished StartAgent actions // Create subagent panel state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) = 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( if let AIAgentActionResultType::StartAgent(
crate::ai::agent::StartAgentResult::Success { agent_id, .. }, crate::ai::agent::StartAgentResult::Success { agent_id, .. },
@@ -4874,7 +4897,11 @@ impl AIBlock {
let action_statuses = me let action_statuses = me
.requested_action_ids .requested_action_ids
.iter() .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(); .collect_vec();
// Detecting links on SearchCodebase tool call outputs // Detecting links on SearchCodebase tool call outputs
@@ -4907,7 +4934,9 @@ impl AIBlock {
view.update_render_read_file_args( view.update_render_read_file_args(
&me.find_state, &me.find_state,
files.clone(), 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(); ctx.notify();
}) })
@@ -4917,7 +4946,9 @@ impl AIBlock {
// Open the AI document pane when documents are created or edited // Open the AI document pane when documents are created or edited
if let Some(action_result) = 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 { match &action_result.result {
AIAgentActionResultType::CreateDocuments( AIAgentActionResultType::CreateDocuments(
@@ -5677,7 +5708,9 @@ impl AIBlock {
/// This hides their keybindings in the UI and makes them less interactive. /// This hides their keybindings in the UI and makes them less interactive.
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) { pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
self.action_model.update(ctx, |action_model, ctx| { 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) { if let Some(edit) = self.requested_edits.get(&action.id) {
edit.view.update(ctx, |view, ctx| view.dismiss(ctx)); edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) { } else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
@@ -5730,7 +5763,12 @@ impl AIBlock {
.view .view
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx)); .update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
self.action_model.update(ctx, |action_model, 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(); ctx.notify();
} }
@@ -5758,12 +5796,11 @@ impl AIBlock {
/// Finds the undismissed passive code diff across all pending actions. /// 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. /// 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> { 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. // 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. // Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
all_pending_actions self.action_model
.iter() .as_ref(app)
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
.find_map(|action| match &action.action { .find_map(|action| match &action.action {
AIAgentActionType::RequestFileEdits { AIAgentActionType::RequestFileEdits {
file_edits: _, file_edits: _,
@@ -5803,7 +5840,10 @@ impl AIBlock {
.is_none_or(|output| { .is_none_or(|output| {
output.get().actions().last().is_none_or(|action| { output.get().actions().last().is_none_or(|action| {
let is_streaming = self.model.status(app).is_streaming(); 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()) is_streaming || status.is_some_and(|status| status.is_running())
}) })
}) })
@@ -5830,7 +5870,7 @@ impl AIBlock {
.any(|(action_id, requested_command)| { .any(|(action_id, requested_command)| {
self.action_model self.action_model
.as_ref(app) .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()) .is_some_and(|status| status.is_running())
&& requested_command.view.as_ref(app).is_header_expanded() && requested_command.view.as_ref(app).is_header_expanded()
}) })
@@ -5930,7 +5970,10 @@ impl AIBlock {
return String::new(); return String::new();
}; };
let output = output.get(); 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 /// 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) // Collect all AI outputs from start_idx to end_idx (exclusive)
let mut combined_result = Vec::new(); let mut combined_result = Vec::new();
for exchange in exchanges.iter().take(end_idx).skip(start_idx) { for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
let formatted_output = let formatted_output = exchange.format_output_for_copy_for_conversation(
exchange.format_output_for_copy(Some(self.action_model.as_ref(app))); Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
);
if !formatted_output.is_empty() { if !formatted_output.is_empty() {
combined_result.push(formatted_output); combined_result.push(formatted_output);
} }
@@ -7158,7 +7203,7 @@ impl TypedActionView for AIBlock {
let Some(result) = self let Some(result) = self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(action_id) .get_action_result(self.client_ids.conversation_id, action_id)
.map(Arc::clone) .map(Arc::clone)
else { else {
continue; continue;
+1 -1
View File
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
let is_cancelled = self let is_cancelled = self
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(&action.id) .get_action_status(self.conversation_id, &action.id)
.is_some_and(|status| status.is_cancelled()); .is_some_and(|status| status.is_cancelled());
if blocked_action.is_none() && !is_cancelled && !should_hide_responses { if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app) 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)] #[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState { struct ActiveCLISubagentState {
initial_requested_command_conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>, initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>, task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>, last_snapshot_at: Option<Instant>,
@@ -171,9 +172,21 @@ impl CLISubagentController {
}); });
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event { 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 mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); 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); active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned(); 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(), 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 mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); 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); active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned(); let action_id = active_block.requested_command_action_id().cloned();
@@ -197,12 +222,13 @@ impl CLISubagentController {
} }
BlocklistAIActionEvent::FinishedAction { BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id, action_id: finished_action_id,
conversation_id,
.. ..
} => { } => {
let action_result = me let action_result = me
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(finished_action_id); .get_action_result(*conversation_id, finished_action_id);
let initial_command_finished_without_snapshot = let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| { action_result.is_some_and(|result| {
matches!( matches!(
@@ -222,14 +248,22 @@ impl CLISubagentController {
.cloned(); .cloned();
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); 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(); let active_command_action_id =
ctx.emit(CLISubagentEvent::UpdatedControl { active_block.requested_command_action_id().cloned();
block_id: active_block.id().clone(), ctx.emit(CLISubagentEvent::UpdatedControl {
requested_command_action_id: active_command_action_id, block_id: active_block.id().clone(),
agent_has_control: active_block.is_agent_in_control(), 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. // 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 { if let Some(snapshot_block_id) = snapshot_block_id {
@@ -244,18 +278,17 @@ impl CLISubagentController {
if initial_command_finished_without_snapshot { if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| { me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some() state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref() || !matches_requested_command_identity(
!= Some(finished_action_id) *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(block_id) = command_finished_block_id {
if let Some(completion) = me me.advance_completed_subagent(&block_id, ctx);
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
} }
} }
_ => (), _ => (),
@@ -322,7 +355,7 @@ impl CLISubagentController {
}; };
drop(terminal_model); 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| { me.controller.update(ctx, |controller, ctx| {
controller.accept_provider_command_completion( controller.accept_provider_command_completion(
completion.conversation_id, completion.conversation_id,
@@ -344,13 +377,9 @@ impl CLISubagentController {
if has_last_snapshot { if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot); ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
} }
if provider_consumed_completion { if provider_accepted_completion {
me.finish_subagent( // The provider controller owns deactivation after it applies the queued
&block_id, // completion at a safe run boundary.
conversation_id,
requested_command_action_id,
ctx,
);
return; return;
} }
if !me.active_subagents_by_block.contains_key(&block_id) { 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 /// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs. /// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) { pub fn track_requested_command(
self.active_subagents_by_block &mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
let state = self
.active_subagents_by_block
.entry(block_id.clone()) .entry(block_id.clone())
.or_default() .or_default();
.initial_requested_command_action_id = Some(action_id.clone()); 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 /// 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(), requested_command_action_id: action_id.clone(),
agent_has_control, 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 self.active_subagents_by_block
.entry(block_id.clone()) .entry(block_id.clone())
.or_default() .or_default()
@@ -1027,6 +1067,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. } RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution | RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. }, | RequestCommandOutputResult::Denylisted { .. },
) )
| AIAgentActionResultType::WriteToLongRunningShellCommand( | 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 !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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1121,4 +1182,51 @@ mod tests {
assert!(!should_nudge_monitor_turn(false, true)); assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false)); 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(); let output = output.get();
output.messages.iter().find_map(|message| { output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.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()); 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 { ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction { .. } BlocklistAIActionEvent::ExecutingAction {
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(), 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 { ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => { ModelEvent::AfterBlockStarted { block_id, .. } => {
+1
View File
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
contents.add_child(output::render( contents.add_child(output::render(
output::Props { output::Props {
conversation_id: self.client_ids.conversation_id,
model: self.model.as_ref(), model: self.model.as_ref(),
state_handles: &self.state_handles, state_handles: &self.state_handles,
action_buttons: &self.action_buttons, action_buttons: &self.action_buttons,
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); 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 let orchestrator_agent_id = props
.model .model
.conversation(app) .conversation(app)
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); 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 { if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else { 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::api::ServerConversationToken;
use crate::ai::agent::comment::ReviewComment; 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::icons::{self, gray_stop_icon, yellow_stop_icon};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ 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. /// Data required to render the AI block output component.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub(crate) struct Props<'a> { pub(crate) struct Props<'a> {
pub(crate) conversation_id: AIConversationId,
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>, pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
pub(super) state_handles: &'a AIBlockStateHandles, pub(super) state_handles: &'a AIBlockStateHandles,
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>, 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 let is_action_done = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(id) .get_action_status(props.conversation_id, id)
.as_ref() .as_ref()
.is_some_and(|status| status.is_done()); .is_some_and(|status| status.is_done());
if !is_action_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 let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
// checks if the read file action result is completed and successful. // 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, id,
.. ..
}) => { }) => {
let action_status = let action_status = props
props.action_model.as_ref(app).get_action_status(id); .action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
if should_render_requested_edit(action_status.as_ref()) { if should_render_requested_edit(action_status.as_ref()) {
if let Some(requested_edit) = props.requested_edits.get(id) { 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 let is_action_done = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(id) .get_action_status(props.conversation_id, id)
.as_ref() .as_ref()
.is_some_and(|status| status.is_done()); .is_some_and(|status| status.is_done());
if !is_action_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 { fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
@@ -1473,7 +1483,10 @@ fn render_search_codebase(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> 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 appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
@@ -1974,7 +1987,10 @@ fn render_read_files(
parsed_skill: Option<&ai::skills::ParsedSkill>, parsed_skill: Option<&ai::skills::ParsedSkill>,
action_index: usize, action_index: usize,
) -> Box<dyn Element> { ) -> 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 appearance = Appearance::as_ref(app);
let formatted_files = let formatted_files =
render_read_files_text(props.into(), file_names, app, appearance, action_index); render_read_files_text(props.into(), file_names, app, appearance, action_index);
@@ -2091,7 +2107,10 @@ fn maybe_render_edit_document(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> 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 // Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) { 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 let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult { let Some(AIAgentActionResult {
@@ -2128,7 +2147,10 @@ fn maybe_render_create_document(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> 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 // Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) { 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 let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult { let Some(AIAgentActionResult {
@@ -2441,7 +2463,7 @@ fn render_suggest_new_conversation(
let status = props let status = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(action_id) .get_action_status(props.conversation_id, action_id)
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult { .unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::SuggestNewConversation( result: AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled, SuggestNewConversationResult::Cancelled,
@@ -2549,7 +2571,10 @@ fn create_formatted_text_for_grep(
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); 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 let is_cancelled = action_status
.as_ref() .as_ref()
.is_some_and(|status| status.is_cancelled()); .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 appearance = Appearance::as_ref(app);
let theme = appearance.theme(); 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 let is_cancelled = action_status
.as_ref() .as_ref()
.is_some_and(|status| status.is_cancelled()); .is_some_and(|status| status.is_cancelled());
@@ -2754,7 +2782,10 @@ fn render_file_retrieval_tool(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); 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); let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
@@ -2871,7 +2902,10 @@ fn render_read_mcp_resource(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); 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); let mut renderable_action = RenderableAction::new(name, app);
@@ -2948,11 +2982,14 @@ fn render_upload_artifact(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); 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 let result = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(action_id) .get_action_result(props.conversation_id, action_id)
.and_then(|result| match &result.result { .and_then(|result| match &result.result {
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result), AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
_ => None, _ => None,
@@ -3011,7 +3048,7 @@ fn render_use_computer(
let has_screenshot = props let has_screenshot = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(action_id) .get_action_result(props.conversation_id, action_id)
.is_some_and(|result| { .is_some_and(|result| {
matches!( matches!(
&result.result, &result.result,
@@ -3057,7 +3094,10 @@ fn render_request_computer_use(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); 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); let mut renderable_action = RenderableAction::new(&request.task_summary, app);
@@ -3638,7 +3678,13 @@ pub fn action_icon<V: View>(
app: &AppContext, app: &AppContext,
) -> galaxyui::elements::Icon { ) -> galaxyui::elements::Icon {
let appearance = Appearance::as_ref(app); 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 { match status {
Some(status) => match status { Some(status) => match status {
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance), AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
+439 -169
View File
@@ -10,7 +10,7 @@ mod pending_response_streams;
pub mod response_stream; pub mod response_stream;
pub(super) mod shared_session; pub(super) mod shared_session;
mod slash_command; mod slash_command;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -23,7 +23,8 @@ use futures::channel::oneshot;
use galaxy_agent_core::{ use galaxy_agent_core::{
turn_control, ExternalWorkId, PendingToolBatch, PendingToolCallState, ProviderRun, turn_control, ExternalWorkId, PendingToolBatch, PendingToolCallState, ProviderRun,
ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRunState, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRunState,
ToolLoopGuard, TurnCommand, TurnCommandSender, TurnRequest, StopReason, ToolLoopGuard, ToolResult, ToolResultStatus, TurnCommand, TurnCommandSender,
TurnRequest,
}; };
use galaxy_core::assertions::safe_assert; use galaxy_core::assertions::safe_assert;
use input_context::{input_context_for_request, parse_context_attachments}; 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 { struct ActiveProviderRunSlot {
stream_id: ResponseStreamId, stream_id: ResponseStreamId,
response_stream: ModelHandle<ResponseStream>, response_stream: ModelHandle<ResponseStream>,
@@ -650,6 +677,13 @@ struct ActiveProviderRunSlot {
monitor_prose_continuations: usize, 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)] #[derive(Clone)]
struct ActiveProviderRunCheckpoint { struct ActiveProviderRunCheckpoint {
run: ProviderRun, run: ProviderRun,
@@ -663,6 +697,7 @@ struct ActiveProviderRunCheckpoint {
struct PreparedRestoredProviderRun { struct PreparedRestoredProviderRun {
snapshot: ActiveProviderRunSnapshot, snapshot: ActiveProviderRunSnapshot,
profiles: BTreeMap<String, ProviderRunProfile>, profiles: BTreeMap<String, ProviderRunProfile>,
projection_was_initialized: bool,
} }
impl ActiveProviderRunCheckpoint { impl ActiveProviderRunCheckpoint {
@@ -706,6 +741,8 @@ struct ActiveProviderRunSnapshot {
root_task_id: TaskId, root_task_id: TaskId,
did_input_contain_user_query: bool, did_input_contain_user_query: bool,
persistence_offset: usize, persistence_offset: usize,
#[serde(default)]
cancellation_reason: Option<CancellationReason>,
committed_provider_batch: Option<ExternalWorkId>, committed_provider_batch: Option<ExternalWorkId>,
#[serde(default)] #[serde(default)]
finished_provider_batch: Option<ExternalWorkId>, finished_provider_batch: Option<ExternalWorkId>,
@@ -746,6 +783,7 @@ impl ActiveProviderRunSnapshot {
root_task_id: slot.root_task_id.clone(), root_task_id: slot.root_task_id.clone(),
did_input_contain_user_query: slot.did_input_contain_user_query, did_input_contain_user_query: slot.did_input_contain_user_query,
persistence_offset: checkpoint.persistence_offset, persistence_offset: checkpoint.persistence_offset,
cancellation_reason: slot.cancellation_reason,
committed_provider_batch: slot.committed_provider_batch.clone(), committed_provider_batch: slot.committed_provider_batch.clone(),
finished_provider_batch: slot.finished_provider_batch.clone(), finished_provider_batch: slot.finished_provider_batch.clone(),
command_action_refs: slot.command_action_refs.clone(), command_action_refs: slot.command_action_refs.clone(),
@@ -996,11 +1034,25 @@ fn normalize_restored_provider_snapshot(
fn apply_restored_provider_command_evidence( fn apply_restored_provider_command_evidence(
conversation_id: AIConversationId, conversation_id: AIConversationId,
snapshot: &mut ActiveProviderRunSnapshot, snapshot: &mut ActiveProviderRunSnapshot,
evidence: RestoredProviderCommandEvidence, evidence: Option<RestoredProviderCommandEvidence>,
) -> Result<(), String> { ) -> Result<(), String> {
let Some(monitor) = snapshot.command_monitor.as_ref() else { let Some(monitor) = snapshot.command_monitor.as_ref() else {
return Ok(()); 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) if evidence.conversation_id != Some(conversation_id)
|| evidence.requested_command_action_id.as_ref() || evidence.requested_command_action_id.as_ref()
!= Some(&monitor.initial_requested_command_action_id) != Some(&monitor.initial_requested_command_action_id)
@@ -1038,6 +1090,20 @@ fn apply_restored_provider_command_evidence(
Ok(()) 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( fn provider_execution_matches_active_work(
run_id: &ProviderRunId, run_id: &ProviderRunId,
active_work_id: Option<&ExternalWorkId>, active_work_id: Option<&ExternalWorkId>,
@@ -1147,6 +1213,7 @@ fn classify_provider_command_result(
command: Some(command.clone()), command: Some(command.clone()),
}), }),
RequestCommandOutputResult::CancelledBeforeExecution RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. } => None, | RequestCommandOutputResult::Denylisted { .. } => None,
}, },
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
@@ -1430,7 +1497,7 @@ fn provider_llm_lifecycle(projection: &ProviderRunProjection) -> Option<Provider
error: Some(error.message.clone()), error: Some(error.message.clone()),
}, },
ProviderRunProjection::ModelEvent { .. } | ProviderRunProjection::ToolBatchReady { .. } => { ProviderRunProjection::ModelEvent { .. } | ProviderRunProjection::ToolBatchReady { .. } => {
return None return None;
} }
}; };
Some(lifecycle) Some(lifecycle)
@@ -1535,8 +1602,11 @@ fn provider_run_terminal_remote_log_record(
} }
enum ProviderDriveMessage { enum ProviderDriveMessage {
Response(warp_multi_agent_api::ResponseEvent), Projection {
Lifecycle(ProviderLlmLifecycle), lifecycle: Option<ProviderLlmLifecycle>,
events: Vec<warp_multi_agent_api::ResponseEvent>,
acknowledgement: oneshot::Sender<Result<(), String>>,
},
Checkpoint { Checkpoint {
checkpoint: ActiveProviderRunCheckpoint, checkpoint: ActiveProviderRunCheckpoint,
acknowledgement: oneshot::Sender<Result<(), String>>, acknowledgement: oneshot::Sender<Result<(), String>>,
@@ -1559,6 +1629,7 @@ pub struct BlocklistAIController {
in_flight_response_streams: PendingResponseStreams, in_flight_response_streams: PendingResponseStreams,
active_provider_runs: HashMap<AIConversationId, ActiveProviderRunSlot>, active_provider_runs: HashMap<AIConversationId, ActiveProviderRunSlot>,
queued_provider_runs: HashMap<AIConversationId, VecDeque<QueuedProviderRun>>,
restoring_provider_runs: HashSet<AIConversationId>, restoring_provider_runs: HashSet<AIConversationId>,
/// The ID of the terminal surface this controller is associated with. /// The ID of the terminal surface this controller is associated with.
@@ -2048,6 +2119,7 @@ impl BlocklistAIController {
terminal_model, terminal_model,
in_flight_response_streams: PendingResponseStreams::new(), in_flight_response_streams: PendingResponseStreams::new(),
active_provider_runs: HashMap::new(), active_provider_runs: HashMap::new(),
queued_provider_runs: HashMap::new(),
restoring_provider_runs: HashSet::new(), restoring_provider_runs: HashSet::new(),
terminal_surface_id, terminal_surface_id,
should_refresh_available_llms_on_stream_finish: false, should_refresh_available_llms_on_stream_finish: false,
@@ -2606,6 +2678,10 @@ impl BlocklistAIController {
if self if self
.in_flight_response_streams .in_flight_response_streams
.has_active_stream_for_conversation(conversation_id, ctx) .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 || self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
@@ -4563,7 +4639,7 @@ impl BlocklistAIController {
.all_inputs() .all_inputs()
.any(|input| input.is_user_query()); .any(|input| input.is_user_query());
ctx.subscribe_to_model(&response_stream, move |me, _, event, ctx| { 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, input_contains_user_query,
event, event,
&response_stream_clone, &response_stream_clone,
@@ -4625,15 +4701,24 @@ impl BlocklistAIController {
} else { } else {
None None
}; };
self.in_flight_response_streams.register_new_stream( if provider_configs.is_some()
response_stream_id.clone(), && self
conversation_data.id, .active_provider_runs
response_stream.clone(), .contains_key(&conversation_data.id)
CancellationReason::FollowUpSubmitted { {
is_for_same_conversation: true, self.in_flight_response_streams
}, .register_additional_stream(response_stream_id.clone(), response_stream.clone());
ctx, } 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 { if let Some((base_provider_config, cli_provider_config)) = provider_configs {
let provider_run_id = ProviderRunId::new(format!( let provider_run_id = ProviderRunId::new(format!(
"{}:{}", "{}:{}",
@@ -4646,37 +4731,50 @@ impl BlocklistAIController {
.expect("conversation exists while starting provider run") .expect("conversation exists while starting provider run")
.get_root_task_id() .get_root_task_id()
.clone(); .clone();
self.active_provider_runs.insert( let slot = ActiveProviderRunSlot {
conversation_data.id, stream_id: response_stream_id.clone(),
ActiveProviderRunSlot { response_stream,
stream_id: response_stream_id.clone(), did_input_contain_user_query: input_contains_user_query,
response_stream, run_id: provider_run_id,
did_input_contain_user_query: input_contains_user_query, root_task_id,
run_id: provider_run_id, projection_target: provider_projection_target
root_task_id, .expect("provider projection target was validated"),
projection_target: provider_projection_target run: None,
.expect("provider projection target was validated"), checkpoint: None,
run: None, turn_control: None,
checkpoint: None, cancellation_reason: None,
turn_control: None, committed_provider_batch: None,
cancellation_reason: None, finished_provider_batch: None,
committed_provider_batch: None, command_action_refs: HashMap::new(),
finished_provider_batch: None, command_monitor: None,
command_action_refs: HashMap::new(), pending_monitor_observation: None,
command_monitor: None, pending_command_completion: None,
pending_monitor_observation: None, monitor_prose_continuations: 0,
pending_command_completion: None, };
monitor_prose_continuations: 0, if self
}, .active_provider_runs
); .contains_key(&conversation_data.id)
self.prepare_active_provider_run( {
conversation_data.id, self.queued_provider_runs
response_stream_id.clone(), .entry(conversation_data.id)
base_provider_config, .or_default()
cli_provider_config, .push_back(QueuedProviderRun {
request_params.clone(), slot,
ctx, 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 // 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 { let Some(task) = conversation.get_task(&snapshot.projection_target.task_id) else {
return Err("restored provider projection task is missing".to_string()); return Err("restored provider projection task is missing".to_string());
}; };
if !task let Some(exchange) = task
.exchanges() .exchanges()
.any(|exchange| exchange.id == snapshot.projection_target.exchange_id) .find(|exchange| exchange.id == snapshot.projection_target.exchange_id)
{ else {
return Err( return Err(
"restored provider projection exchange is missing from its task" "restored provider projection exchange is missing from its task"
.to_string(), .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 { let projection_was_initialized = match history_validation {
self.fail_restored_provider_run(conversation_id, error, ctx); Ok(initialized) => initialized,
return; Err(error) => {
} self.fail_restored_provider_run(conversation_id, error, ctx);
return;
}
};
if let Err(error) = normalize_restored_provider_snapshot(&mut snapshot) { if let Err(error) = normalize_restored_provider_snapshot(&mut snapshot) {
self.fail_restored_provider_run(conversation_id, error, ctx); self.fail_restored_provider_run(conversation_id, error, ctx);
@@ -4868,7 +4974,11 @@ impl BlocklistAIController {
ProviderRunProfile::new(cli_runtime, cli_monitor_request.clone()), 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| { move |me, result, ctx| {
me.handle_prepared_restored_provider_run(conversation_id, result, ctx); me.handle_prepared_restored_provider_run(conversation_id, result, ctx);
@@ -4886,19 +4996,18 @@ impl BlocklistAIController {
}; };
let evidence = { let evidence = {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
let block = terminal_model terminal_model
.block_list() .block_list()
.block_with_id(&monitor.block_id) .block_with_id(&monitor.block_id)
.ok_or_else(|| "restored provider command block is missing".to_string())?; .map(|block| RestoredProviderCommandEvidence {
RestoredProviderCommandEvidence { conversation_id: block.ai_conversation_id(),
conversation_id: block.ai_conversation_id(), requested_command_action_id: block.requested_command_action_id().cloned(),
requested_command_action_id: block.requested_command_action_id().cloned(), cli_task_id: block.cli_subagent_task_id().cloned(),
cli_task_id: block.cli_subagent_task_id().cloned(), command: block.command_to_string(),
command: block.command_to_string(), state: block.state(),
state: block.state(), output: block.output_to_string(),
output: block.output_to_string(), exit_code: block.exit_code().value(),
exit_code: block.exit_code().value(), })
}
}; };
apply_restored_provider_command_evidence(conversation_id, snapshot, evidence) apply_restored_provider_command_evidence(conversation_id, snapshot, evidence)
} }
@@ -4930,7 +5039,11 @@ impl BlocklistAIController {
self.restoring_provider_runs.remove(&conversation_id); self.restoring_provider_runs.remove(&conversation_id);
return; return;
} }
let PreparedRestoredProviderRun { snapshot, profiles } = match result { let PreparedRestoredProviderRun {
snapshot,
profiles,
projection_was_initialized,
} = match result {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(error) => { Err(error) => {
self.fail_restored_provider_run(conversation_id, error.to_string(), ctx); self.fail_restored_provider_run(conversation_id, error.to_string(), ctx);
@@ -4948,6 +5061,7 @@ impl BlocklistAIController {
root_task_id, root_task_id,
did_input_contain_user_query, did_input_contain_user_query,
persistence_offset, persistence_offset,
cancellation_reason,
committed_provider_batch, committed_provider_batch,
finished_provider_batch, finished_provider_batch,
command_action_refs, command_action_refs,
@@ -4960,13 +5074,21 @@ impl BlocklistAIController {
let transcript = provider_run.transcript(); let transcript = provider_run.transcript();
let offset = persistence_offset.min(transcript.len()); let offset = persistence_offset.min(transcript.len());
let messages_sent = Arc::new(std::sync::Mutex::new(transcript[offset..].to_vec())); 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, Ok(coordinator) => coordinator,
Err(error) => { Err(error) => {
self.fail_restored_provider_run(conversation_id, error.to_string(), ctx); self.fail_restored_provider_run(conversation_id, error.to_string(), ctx);
return; 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 model = LLMId::from(response_config.model_id.as_str());
let ai_identifiers = AIIdentifiers { let ai_identifiers = AIIdentifiers {
client_conversation_id: Some(conversation_id), client_conversation_id: Some(conversation_id),
@@ -4984,7 +5106,7 @@ impl BlocklistAIController {
let stream_id = response_stream.as_ref(ctx).id().clone(); let stream_id = response_stream.as_ref(ctx).id().clone();
let response_stream_clone = response_stream.clone(); let response_stream_clone = response_stream.clone();
ctx.subscribe_to_model(&response_stream, move |me, _, event, ctx| { 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, did_input_contain_user_query,
event, event,
&response_stream_clone, &response_stream_clone,
@@ -5032,7 +5154,10 @@ impl BlocklistAIController {
projection_target, projection_target,
run: Some(ActiveProviderRun { run: Some(ActiveProviderRun {
coordinator, coordinator,
projector: ProviderRunResponseProjector::restored(response_config.clone()), projector: ProviderRunResponseProjector::restored(
response_config.clone(),
projection_was_initialized,
),
response_config, response_config,
action_context, action_context,
messages_sent, messages_sent,
@@ -5040,7 +5165,7 @@ impl BlocklistAIController {
}), }),
checkpoint: None, checkpoint: None,
turn_control: None, turn_control: None,
cancellation_reason: None, cancellation_reason,
committed_provider_batch, committed_provider_batch,
finished_provider_batch, finished_provider_batch,
command_action_refs, command_action_refs,
@@ -5320,26 +5445,29 @@ impl BlocklistAIController {
let checkpoint_sender = sender.clone(); let checkpoint_sender = sender.clone();
let result = run let result = run
.coordinator .coordinator
.drive_until_blocked_with_checkpoint( .drive_until_blocked_with_acknowledgements(
turn_control, turn_control,
|projection| { |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 projection_sender
.try_send(ProviderDriveMessage::Lifecycle(lifecycle)) .send(ProviderDriveMessage::Projection {
lifecycle,
events,
acknowledgement,
})
.await
.map_err(|_| { .map_err(|_| {
"provider lifecycle projection receiver was closed" "provider projection receiver was closed".to_string()
.to_string()
})?; })?;
} receiver.await.map_err(|_| {
for event in run.projector.project(projection)? { "provider projection acknowledgement was dropped".to_string()
projection_sender })?
.try_send(ProviderDriveMessage::Response(event)) })
.map_err(|_| {
"provider response projection receiver was closed"
.to_string()
})?;
}
Ok(())
}, },
move |provider_run| { move |provider_run| {
let checkpoint_sender = checkpoint_sender.clone(); let checkpoint_sender = checkpoint_sender.clone();
@@ -5385,29 +5513,40 @@ impl BlocklistAIController {
return; return;
} }
match message { match message {
ProviderDriveMessage::Response(event) => { ProviderDriveMessage::Projection {
lifecycle,
events,
acknowledgement,
} => {
let response_stream = slot.response_stream.clone(); let response_stream = slot.response_stream.clone();
let did_input_contain_user_query = slot.did_input_contain_user_query; let did_input_contain_user_query = slot.did_input_contain_user_query;
let event = ResponseStream::projected_event(event); let mut result = Ok(());
self.handle_response_stream_event( for event in events {
did_input_contain_user_query, let event = ResponseStream::projected_event(event);
&event, if let Err(error) = self.handle_response_stream_event(
&response_stream, did_input_contain_user_query,
ctx, &event,
); &response_stream,
} ctx,
ProviderDriveMessage::Lifecycle(lifecycle) => { ) {
result = Err(error);
break;
}
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
remote_logging::log_model_event( if let Some(lifecycle) = lifecycle.as_ref() {
ctx, remote_logging::log_model_event(
provider_llm_lifecycle_remote_log_record( ctx,
conversation_id, provider_llm_lifecycle_remote_log_record(
stream_id, conversation_id,
&lifecycle, stream_id,
), lifecycle,
); ),
);
}
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
let _ = lifecycle; let _ = lifecycle;
let _ = acknowledgement.send(result);
} }
ProviderDriveMessage::Checkpoint { ProviderDriveMessage::Checkpoint {
checkpoint, checkpoint,
@@ -5691,35 +5830,60 @@ impl BlocklistAIController {
batch: PendingToolBatch, batch: PendingToolBatch,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let conversion = self let Some(run) = self
.active_provider_runs .active_provider_runs
.get(&conversation_id) .get_mut(&conversation_id)
.and_then(|slot| slot.run.as_ref()) .and_then(|slot| slot.run.as_mut())
.map(|run| { else {
batch return;
.calls };
.iter() let (converted_actions, invalid_results) =
.filter(|pending| pending.state.result().is_none()) convert_provider_tool_batch(&run.action_context, &batch);
.map(|pending| { for result in &invalid_results {
run.action_context if let Err(error) = run
.action_from_tool_call(&pending.call) .coordinator
.map(|action| { .run_mut()
( .complete_tool(&batch.work_id, result.clone())
action, {
matches!(pending.state, PendingToolCallState::RecoveryPending), self.fail_active_provider_run(
) conversation_id,
}) format!("failed to record invalid provider tool input: {error}"),
}) ctx,
.collect::<Result<Vec<_>, _>>() );
});
let converted_actions = match conversion {
Some(Ok(actions)) => actions,
Some(Err(message)) => {
self.fail_active_provider_run(conversation_id, message, ctx);
return; 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] let stream_id = self.active_provider_runs[&conversation_id]
.stream_id .stream_id
.clone(); .clone();
@@ -5806,7 +5970,7 @@ impl BlocklistAIController {
actions, actions,
recovery_action_ids, recovery_action_ids,
conversation_id, conversation_id,
&batch, &executable_batch,
ctx, 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( fn handle_provider_actions_finished(
&mut self, &mut self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -6219,7 +6410,7 @@ impl BlocklistAIController {
let did_input_contain_user_query = slot.did_input_contain_user_query; let did_input_contain_user_query = slot.did_input_contain_user_query;
for event in events { for event in events {
let event = ResponseStream::projected_event(event); let event = ResponseStream::projected_event(event);
self.handle_response_stream_event( let _ = self.handle_response_stream_event(
did_input_contain_user_query, did_input_contain_user_query,
&event, &event,
&response_stream, &response_stream,
@@ -6237,9 +6428,35 @@ impl BlocklistAIController {
), ),
); );
match outcome { match outcome {
ProviderRunOutcome::Completed(_) => { ProviderRunOutcome::Completed(completion) => match completion.stop_reason {
self.finalize_completed_provider_conversation(conversation_id, ctx); 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. // Failed outcomes are finalized by the projected InternalError event.
ProviderRunOutcome::Failed(_) => {} ProviderRunOutcome::Failed(_) => {}
ProviderRunOutcome::Cancelled { .. } => { ProviderRunOutcome::Cancelled { .. } => {
@@ -6314,6 +6531,13 @@ impl BlocklistAIController {
response_stream: &ModelHandle<ResponseStream>, response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>, 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) { if let Err(error) = self.clear_persisted_active_provider_run(conversation_id, ctx) {
log::error!("Failed to clear persisted provider run during cleanup: {error}"); 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); request_usage_model.refresh_request_usage_async(ctx);
}); });
self.maybe_refresh_ai_overages(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( fn cancel_active_provider_run(
@@ -6342,53 +6598,59 @@ impl BlocklistAIController {
reason: CancellationReason, reason: CancellationReason,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> bool { ) -> 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; return false;
}; };
slot.cancellation_reason = Some(reason); 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 { if let Some(turn_control) = &slot.turn_control {
let _ = turn_control.try_send(TurnCommand::Cancel); 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() { if !run.coordinator.run().is_terminal() {
let _ = run.coordinator.run_mut().cancel(reason.to_string()); let _ = run.coordinator.run_mut().cancel(reason.to_string());
} }
if let Ok(mut messages_sent) = run.messages_sent.lock() { true
let transcript = run.coordinator.run().transcript(); } else {
let offset = run.persistence_offset.min(transcript.len()); false
*messages_sent = transcript[offset..].to_vec(); };
}
// 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| { self.action_model.update(ctx, |action_model, ctx| {
action_model.cancel_all_pending_actions(conversation_id, Some(reason), ctx); action_model.cancel_all_pending_actions(conversation_id, Some(reason), ctx);
}); });
let cancellation_outcome = reason.conversation_outcome();
if FeatureFlag::AgentSharedSessions.is_enabled() if FeatureFlag::AgentSharedSessions.is_enabled()
&& !matches!(cancellation_outcome, CancellationOutcome::KeepInProgress) && !matches!(cancellation_outcome, CancellationOutcome::KeepInProgress)
{ {
self.send_cancellation_to_viewers(ctx); 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) { if matches!(cancellation_outcome, CancellationOutcome::Cancelled) {
self.set_input_mode_for_cancellation(ctx); 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 true
} }
@@ -6535,7 +6797,7 @@ impl BlocklistAIController {
) { ) {
let stream_clone = stream.clone(); let stream_clone = stream.clone();
ctx.subscribe_to_model(&stream, move |me, _, event, ctx| { 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( self.in_flight_response_streams.register_new_stream(
stream_id, stream_id,
@@ -6739,7 +7001,7 @@ impl BlocklistAIController {
event: &ResponseStreamEvent, event: &ResponseStreamEvent,
response_stream: &ModelHandle<ResponseStream>, response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) -> Result<(), String> {
let stream_id = response_stream.as_ref(ctx).id().clone(); let stream_id = response_stream.as_ref(ctx).id().clone();
match event { match event {
@@ -6749,14 +7011,16 @@ impl BlocklistAIController {
.conversation_for_response_stream(&stream_id) .conversation_for_response_stream(&stream_id)
else { else {
log::warn!("Could not find conversation for response stream: {stream_id:?}"); 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 { let Some(event) = event.consume() else {
debug_assert!( debug_assert!(
false, false,
"This model should only have a single subscriber that takes ownership over the event." "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); let history_model = BlocklistAIHistoryModel::handle(ctx);
match event { match event {
@@ -6794,7 +7058,7 @@ impl BlocklistAIController {
} }
} }
let Some(event) = event.r#type else { let Some(event) = event.r#type else {
return; return Err("response event did not contain a type".to_string());
}; };
match event { match event {
warp_multi_agent_api::response_event::Type::Init(init_event) => { warp_multi_agent_api::response_event::Type::Init(init_event) => {
@@ -6909,6 +7173,9 @@ impl BlocklistAIController {
log::error!( log::error!(
"Failed to apply client actions to conversation: {e:?}" "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!( log::warn!(
"Could not find conversation for response stream: {stream_id:?}" "Could not find conversation for response stream: {stream_id:?}"
); );
return; return Err(format!(
"could not find conversation for response stream {stream_id:?}"
));
}; };
id id
} }
@@ -6980,7 +7249,7 @@ impl BlocklistAIController {
}) })
else { else {
log::warn!("Conversation not found."); log::warn!("Conversation not found.");
return; return Err("conversation not found for completed response stream".to_string());
}; };
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() { 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 { for new_exchange_id in new_exchange_ids {
let Some(exchange) = exchanges.exchange_with_id(new_exchange_id) else { let Some(exchange) = exchanges.exchange_with_id(new_exchange_id) else {
log::warn!("Exchange not found."); log::warn!("Exchange not found.");
return; return Err("exchange not found for completed response stream".to_string());
}; };
was_passive_request |= exchange.has_passive_request(); was_passive_request |= exchange.has_passive_request();
is_any_exchange_unfinished |= !exchange.output_status.is_finished(); is_any_exchange_unfinished |= !exchange.output_status.is_finished();
@@ -7328,6 +7597,7 @@ impl BlocklistAIController {
self.maybe_refresh_ai_overages(ctx); self.maybe_refresh_ai_overages(ctx);
} }
} }
Ok(())
} }
/// Sets the terminal input state after an AI request is cancelled. /// Sets the terminal input state after an AI request is cancelled.
@@ -91,6 +91,14 @@ impl PendingResponseStreams {
self.streams.insert(stream_id, stream); 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) { pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id); self.streams.remove(stream_id);
} }
@@ -142,9 +150,11 @@ impl PendingResponseStreams {
for response_stream in streams_to_cancel.into_iter() { for response_stream in streams_to_cancel.into_iter() {
log::info!( log::info!(
"Canceling active stream for conversation_id={conversation_id:?}, \ "Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}", reason={reason}"
std::backtrace::Backtrace::force_capture()
); );
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
}
response_stream.update(ctx, |stream, ctx| { response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx) stream.cancel(reason, conversation_id, ctx)
}); });
@@ -354,7 +354,7 @@ impl BlocklistAIController {
if self if self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(&result.id) .get_action_result(conversation_id, &result.id)
.is_none() .is_none()
{ {
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
+454 -9
View File
@@ -25,6 +25,7 @@ use crate::ai::agent::{
WriteToLongRunningShellCommandResult, WriteToLongRunningShellCommandResult,
}; };
use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::action_model::StartAgentWaitPolicy;
use crate::ai::blocklist::{ use crate::ai::blocklist::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, PendingAttachment, PendingFile, RequestInput, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, PendingAttachment, PendingFile, RequestInput,
ResponseStream, ResponseStreamId, StartAgentExecutor, ResponseStream, ResponseStreamId, StartAgentExecutor,
@@ -265,6 +266,7 @@ fn provider_snapshot(conversation_id: AIConversationId) -> super::ActiveProvider
root_task_id: task_id, root_task_id: task_id,
did_input_contain_user_query: true, did_input_contain_user_query: true,
persistence_offset: 0, persistence_offset: 0,
cancellation_reason: None,
committed_provider_batch: None, committed_provider_batch: None,
finished_provider_batch: None, finished_provider_batch: None,
command_action_refs: HashMap::new(), 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( fn start_snapshot_tool(
snapshot: &mut super::ActiveProviderRunSnapshot, snapshot: &mut super::ActiveProviderRunSnapshot,
call_id: &str, call_id: &str,
@@ -305,6 +579,105 @@ fn start_snapshot_tool(
batch.work_id 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( fn attach_snapshot_command_monitor(
snapshot: &mut super::ActiveProviderRunSnapshot, snapshot: &mut super::ActiveProviderRunSnapshot,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -548,7 +921,7 @@ fn restored_active_command_rebuilds_monitor_observation() {
super::apply_restored_provider_command_evidence( super::apply_restored_provider_command_evidence(
conversation_id, conversation_id,
&mut snapshot, &mut snapshot,
super::RestoredProviderCommandEvidence { Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id), requested_command_action_id: Some(action_id),
cli_task_id: Some(cli_task_id.clone()), cli_task_id: Some(cli_task_id.clone()),
@@ -556,7 +929,7 @@ fn restored_active_command_rebuilds_monitor_observation() {
state: BlockState::Executing, state: BlockState::Executing,
output: "running".to_owned(), output: "running".to_owned(),
exit_code: 0, exit_code: 0,
}, }),
) )
.unwrap(); .unwrap();
@@ -582,7 +955,7 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
super::apply_restored_provider_command_evidence( super::apply_restored_provider_command_evidence(
conversation_id, conversation_id,
&mut snapshot, &mut snapshot,
super::RestoredProviderCommandEvidence { Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id.clone()), requested_command_action_id: Some(action_id.clone()),
cli_task_id: Some(cli_task_id), cli_task_id: Some(cli_task_id),
@@ -590,7 +963,7 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
state: BlockState::DoneWithExecution, state: BlockState::DoneWithExecution,
output: "done".to_owned(), output: "done".to_owned(),
exit_code: 17, exit_code: 17,
}, }),
) )
.unwrap(); .unwrap();
@@ -608,6 +981,78 @@ fn restored_completed_command_rebuilds_exact_completion_evidence() {
assert_eq!(completion.exit_code, 17); 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] #[test]
fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() { fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
let conversation_id = AIConversationId::new(); let conversation_id = AIConversationId::new();
@@ -619,7 +1064,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
super::apply_restored_provider_command_evidence( super::apply_restored_provider_command_evidence(
conversation_id, conversation_id,
&mut snapshot, &mut snapshot,
super::RestoredProviderCommandEvidence { Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(AIConversationId::new()), conversation_id: Some(AIConversationId::new()),
requested_command_action_id: Some(action_id.clone()), requested_command_action_id: Some(action_id.clone()),
cli_task_id: Some(cli_task_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, state: BlockState::Executing,
output: String::new(), output: String::new(),
exit_code: 0, exit_code: 0,
}, }),
) )
.unwrap_err(), .unwrap_err(),
"restored provider command block identity does not match" "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( super::apply_restored_provider_command_evidence(
conversation_id, conversation_id,
&mut snapshot, &mut snapshot,
super::RestoredProviderCommandEvidence { Some(super::RestoredProviderCommandEvidence {
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
requested_command_action_id: Some(action_id), requested_command_action_id: Some(action_id),
cli_task_id: Some(cli_task_id), cli_task_id: Some(cli_task_id),
@@ -644,7 +1089,7 @@ fn restored_command_rejects_mismatched_or_invalid_terminal_evidence() {
state: BlockState::Background, state: BlockState::Background,
output: String::new(), output: String::new(),
exit_code: 0, exit_code: 0,
}, }),
) )
.unwrap_err(), .unwrap_err(),
"restored provider command block has an invalid state" "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(), "child".to_owned(),
parent_conversation_id, parent_conversation_id,
child_conversation_id, child_conversation_id,
None, StartAgentWaitPolicy::Completion,
ctx, ctx,
) )
}); });
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
}; };
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| { 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; return;
} }
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
/// conversations still render deterministically. /// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> { fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
let action_model = self.action_model.as_ref(app); 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); return Some(status);
} }
@@ -695,12 +695,26 @@ impl CodeDiffView {
session_platform, session_platform,
ctx, ctx,
); );
let action_id = (*action_id).clone();
ctx.subscribe_to_model( ctx.subscribe_to_model(
&action_model, &action_model,
move |me, action_model, event, ctx| match event { move |me, action_model, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => { BlocklistAIActionEvent::FinishedAction {
match action_model.as_ref(ctx).get_action_status(&me.action_id) { 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) => { Some(AIActionStatus::Blocked) => {
me.state = CodeDiffState::WaitingForUser; me.state = CodeDiffState::WaitingForUser;
ctx.notify(); ctx.notify();
@@ -412,7 +412,7 @@ impl RequestedCommandView {
let is_finished = action_model let is_finished = action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(&action_id) .get_action_result(client_ids.conversation_id, &action_id)
.is_some(); .is_some();
if !is_finished { if !is_finished {
@@ -424,16 +424,24 @@ impl RequestedCommandView {
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id, .. action_id,
} if *action_id == me.action_id => { conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
if me.action_type.is_requested_command() { if me.action_type.is_requested_command() {
me.ensure_editor(ctx); me.ensure_editor(ctx);
} }
me.set_is_header_expanded(true, ctx); me.set_is_header_expanded(true, ctx);
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::ExecutingAction { action_id, .. } BlocklistAIActionEvent::ExecutingAction {
if *action_id == me.action_id => 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. // 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() { if me.action_model.as_ref(ctx).is_view_only() {
@@ -467,11 +475,15 @@ impl RequestedCommandView {
} }
ctx.notify(); 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 let Some(action_result) = me
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(action_id) .get_action_result(me.client_ids.conversation_id, action_id)
.cloned() .cloned()
else { else {
log::info!("Got finished action event without result: {action_id}."); 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 { fn is_waiting_for_user_confirmation(&self, app: &AppContext) -> bool {
self.action_model self.action_model
.as_ref(app) .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()) .is_some_and(|status| status.is_blocked())
} }
@@ -750,7 +762,9 @@ impl RequestedCommandView {
let Some(mouse_state_handle) = let Some(mouse_state_handle) =
self.citation_state_handles.get(copied_citation).cloned() self.citation_state_handles.get(copied_citation).cloned()
else { 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; return None;
}; };
render_citation( render_citation(
@@ -1108,7 +1122,7 @@ impl RequestedCommandView {
let action_status = self let action_status = self
.action_model .action_model
.as_ref(app) .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 title: Cow<'static, str>;
let mut font_override = None; let mut font_override = None;
@@ -1457,7 +1471,7 @@ impl View for RequestedCommandView {
let action_status = self let action_status = self
.action_model .action_model
.as_ref(app) .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 let is_last_output_message_in_output = self
.block_model .block_model
@@ -1635,6 +1649,7 @@ impl View for RequestedCommandView {
let container = render_tool_pane_shell( let container = render_tool_pane_shell(
content.finish(), content.finish(),
has_highlighted_border, has_highlighted_border,
self.is_header_expanded,
should_remove_bottom_margin, should_remove_bottom_margin,
app, app,
); );
@@ -489,29 +489,36 @@ impl RunAgentsCardView {
// Re-render when this action finishes or becomes blocked. // Re-render when this action finishes or becomes blocked.
let action_id_for_action_events = action_id.clone(); let action_id_for_action_events = action_id.clone();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event { ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if event.conversation_id().is_some_and(|conversation_id| {
if action_id == &action_id_for_action_events => me.block_model.conversation_id(ctx) != Some(conversation_id)
{ }) {
ctx.notify(); return;
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. } match event {
if action_id == &action_id_for_action_events => BlocklistAIActionEvent::FinishedAction { 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 ctx.notify();
// 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(); 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. // Repopulate the model picker when available Warp LLMs change.
@@ -713,8 +720,11 @@ impl RunAgentsCardView {
let request = self.state.to_request(); let request = self.state.to_request();
self.emit_decision(RunAgentsCardDecision::Accept, ctx); self.emit_decision(RunAgentsCardDecision::Accept, ctx);
let action_id = self.action_id.clone(); 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| { 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() { if self.block_model.is_restored() {
return; return;
} }
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
if matches!( if matches!(
self.action_model self.action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_status(&self.action_id), .get_action_status(conversation_id, &self.action_id),
Some(AIActionStatus::Finished(_)) | Some(AIActionStatus::RunningAsync) Some(AIActionStatus::Finished(_)) | Some(AIActionStatus::RunningAsync)
) { ) {
return; return;
@@ -1093,9 +1106,13 @@ impl View for RunAgentsCardView {
fn render(&self, app: &AppContext) -> Box<dyn Element> { fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = self let status = self
.action_model .block_model
.as_ref(app) .conversation_id(app)
.get_action_status(&self.action_id); .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 Some(AIActionStatus::Finished(result)) = &status {
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result { if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
@@ -1208,8 +1225,16 @@ impl TypedActionView for RunAgentsCardView {
RunAgentsCardViewAction::AcceptWithoutOrchestration => { RunAgentsCardViewAction::AcceptWithoutOrchestration => {
self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx); self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx);
let action_id = self.action_id.clone(); 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| { 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 => { RunAgentsCardViewAction::ToggleAcceptMenu => {
@@ -1537,14 +1562,31 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
.iter() .iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. })) .filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
.count(); .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 { let label = if total == 1 {
"Spawned 1 agent".to_string() "Spawned 1 agent".to_string()
} else { } else {
format!("Spawned {total} agents") format!("Spawned {total} agents")
}; };
(label, StatusKind::Success) (label, StatusKind::Success)
} else if launched == 0 { } else if successful == 0 {
// Every child failed to launch: surface a terminal failure // Every child failed to launch: surface a terminal failure
// rather than the in-progress-looking mixed state. // rather than the in-progress-looking mixed state.
let label = if total == 1 { let label = if total == 1 {
@@ -1555,7 +1597,7 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
(label, StatusKind::Failure) (label, StatusKind::Failure)
} else { } else {
( (
format!("Spawned {launched} of {total} agents"), format!("Spawned {successful} of {total} agents"),
StatusKind::Mixed, StatusKind::Mixed,
) )
} }
@@ -1713,7 +1755,8 @@ fn render_run_agents_child_row(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind { 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) conversation_id_for_agent_id(agent_id, app)
} }
RunAgentsAgentOutcomeKind::Failed { .. } => None, RunAgentsAgentOutcomeKind::Failed { .. } => None,
@@ -1757,6 +1800,9 @@ fn render_run_agents_child_row(
RunAgentsAgentOutcomeKind::Launched { .. } => { RunAgentsAgentOutcomeKind::Launched { .. } => {
(ConversationStatus::Success, "Started".to_string()) (ConversationStatus::Success, "Started".to_string())
} }
RunAgentsAgentOutcomeKind::Completed { .. } => {
(ConversationStatus::Success, "Completed".to_string())
}
RunAgentsAgentOutcomeKind::Failed { error } => ( RunAgentsAgentOutcomeKind::Failed { error } => (
ConversationStatus::Error, ConversationStatus::Error,
if error.trim().is_empty() { 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 { fn launched_result(agents: Vec<RunAgentsAgentOutcome>) -> RunAgentsResult {
RunAgentsResult::Launched { RunAgentsResult::Launched {
model_id: "auto".to_string(), model_id: "auto".to_string(),
@@ -368,6 +378,30 @@ mod format_terminal_state_tests {
assert!(matches!(kind, StatusKind::Mixed)); 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] #[test]
fn all_failed_uses_failure_status_not_mixed() { fn all_failed_uses_failure_status_not_mixed() {
let result = launched_result(vec![ let result = launched_result(vec![
@@ -14,6 +14,7 @@ use crate::ai::blocklist::block::view_impl::{
pub(crate) fn render_tool_pane_shell( pub(crate) fn render_tool_pane_shell(
content: Box<dyn Element>, content: Box<dyn Element>,
has_highlighted_border: bool, has_highlighted_border: bool,
spans_conversation_width: bool,
should_remove_bottom_margin: bool, should_remove_bottom_margin: bool,
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
@@ -25,7 +26,7 @@ pub(crate) fn render_tool_pane_shell(
}; };
Container::new(content) Container::new(content)
.with_margin_left(if has_highlighted_border { .with_margin_left(if has_highlighted_border || spans_conversation_width {
CONTENT_HORIZONTAL_PADDING CONTENT_HORIZONTAL_PADDING
} else { } else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16. CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
+1
View File
@@ -61,6 +61,7 @@ pub mod request_usage_model;
pub(crate) mod restored_conversations; pub(crate) mod restored_conversations;
pub(crate) mod runtime; pub(crate) mod runtime;
pub(crate) mod skills; pub(crate) mod skills;
pub(crate) mod tool_diagnostics;
pub(crate) mod voice; pub(crate) mod voice;
pub use agent_tips::*; pub use agent_tips::*;
use galaxyui::AppContext; use galaxyui::AppContext;
+102 -9
View File
@@ -62,9 +62,12 @@ impl ProviderRunResponseProjector {
} }
} }
pub(crate) fn restored(config: RuntimeResponseConfig) -> Self { pub(crate) fn restored(
config: RuntimeResponseConfig,
projection_was_initialized: bool,
) -> Self {
Self { Self {
translator: RuntimeResponseTranslator::restored(config), translator: RuntimeResponseTranslator::restored(config, projection_was_initialized),
has_started_model_turn: false, has_started_model_turn: false,
finished: false, finished: false,
} }
@@ -88,9 +91,11 @@ impl ProviderRunResponseProjector {
}) })
} }
ProviderRunProjection::ModelEvent { event, .. } => self.translator.translate(event), ProviderRunProjection::ModelEvent { event, .. } => self.translator.translate(event),
ProviderRunProjection::ModelRetry { .. } => {
Ok(self.translator.discard_failed_turn_output())
}
ProviderRunProjection::ModelTurnRequested { .. } ProviderRunProjection::ModelTurnRequested { .. }
| ProviderRunProjection::ModelTurnFinished { .. } | ProviderRunProjection::ModelTurnFinished { .. }
| ProviderRunProjection::ModelRetry { .. }
| ProviderRunProjection::ToolBatchReady { .. } => Ok(Vec::new()), | ProviderRunProjection::ToolBatchReady { .. } => Ok(Vec::new()),
} }
} }
@@ -130,8 +135,15 @@ impl RuntimeResponseTranslator {
Self::with_initialization(config, false) Self::with_initialization(config, false)
} }
pub(crate) fn restored(config: RuntimeResponseConfig) -> Self { pub(crate) fn restored(
Self::with_initialization(config, true) mut config: RuntimeResponseConfig,
projection_was_initialized: bool,
) -> Self {
// The task and exchange already exist in restored history. If its output was never
// initialized, replay only the stream Init rather than duplicating task/input messages.
config.needs_create_task = false;
config.user_query = None;
Self::with_initialization(config, projection_was_initialized)
} }
fn with_initialization(config: RuntimeResponseConfig, initialized: bool) -> Self { fn with_initialization(config: RuntimeResponseConfig, initialized: bool) -> Self {
@@ -143,7 +155,7 @@ impl RuntimeResponseTranslator {
reasoning_message_id: None, reasoning_message_id: None,
activity_message_ids: HashMap::new(), activity_message_ids: HashMap::new(),
activities: HashMap::new(), activities: HashMap::new(),
has_visible_output: initialized, has_visible_output: false,
usage: Usage::default(), usage: Usage::default(),
context_usage: None, context_usage: None,
} }
@@ -163,9 +175,7 @@ impl RuntimeResponseTranslator {
} }
AgentEvent::ReasoningCompleted { text, .. } => { AgentEvent::ReasoningCompleted { text, .. } => {
self.initialize(&mut events); self.initialize(&mut events);
if self.reasoning_message_id.is_none() && !text.is_empty() { self.complete_reasoning(&text, &mut events);
self.add_or_append_reasoning(&text, &mut events);
}
} }
AgentEvent::RuntimeActivityUpdated { activity } => { AgentEvent::RuntimeActivityUpdated { activity } => {
if self.config.capabilities.host_tool_execution { if self.config.capabilities.host_tool_execution {
@@ -244,6 +254,25 @@ impl RuntimeResponseTranslator {
self.reasoning_message_id = None; self.reasoning_message_id = None;
} }
fn discard_failed_turn_output(&mut self) -> Vec<ResponseEvent> {
let mut events = Vec::new();
if let Some(message_id) = self.text_message_id.take() {
events.push(build_replace_text_message(
&self.config.task_id,
&message_id,
"",
));
}
if let Some(message_id) = self.reasoning_message_id.take() {
events.push(build_replace_reasoning_message(
&self.config.task_id,
&message_id,
"",
));
}
events
}
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) { pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
let task_id = task_id.into(); let task_id = task_id.into();
if self.config.task_id == task_id { if self.config.task_id == task_id {
@@ -315,6 +344,18 @@ impl RuntimeResponseTranslator {
} }
} }
fn complete_reasoning(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
if let Some(message_id) = &self.reasoning_message_id {
events.push(build_replace_reasoning_message(
&self.config.task_id,
message_id,
text,
));
} else if !text.is_empty() {
self.add_or_append_reasoning(text, events);
}
}
fn upsert_runtime_activity( fn upsert_runtime_activity(
&mut self, &mut self,
activity: RuntimeActivity, activity: RuntimeActivity,
@@ -441,6 +482,58 @@ fn build_reasoning_message(
runtime_client_action(action) runtime_client_action(action)
} }
fn build_replace_reasoning_message(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
let message = api::Message {
id: message_id.to_owned(),
task_id: task_id.to_owned(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: Vec::new(),
fetched_memories: Vec::new(),
message: Some(api::message::Message::AgentReasoning(
api::message::AgentReasoning {
reasoning: text.to_owned(),
finished_duration: None,
},
)),
};
runtime_client_action(api::client_action::Action::UpdateTaskMessage(
api::client_action::UpdateTaskMessage {
task_id: task_id.to_owned(),
message: Some(message),
mask: Some(prost_types::FieldMask {
paths: vec!["agent_reasoning.reasoning".to_owned()],
}),
},
))
}
fn build_replace_text_message(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
runtime_client_action(api::client_action::Action::UpdateTaskMessage(
api::client_action::UpdateTaskMessage {
task_id: task_id.to_owned(),
message: Some(api::Message {
id: message_id.to_owned(),
task_id: task_id.to_owned(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: Vec::new(),
fetched_memories: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: text.to_owned(),
},
)),
}),
mask: Some(prost_types::FieldMask {
paths: vec!["agent_output.text".to_owned()],
}),
},
))
}
fn runtime_activity_fallback_text(activity: &RuntimeActivity) -> String { fn runtime_activity_fallback_text(activity: &RuntimeActivity) -> String {
let title = &activity.title; let title = &activity.title;
let status = activity.status.as_ref().map(|status| match status { let status = activity.status.as_ref().map(|status| match status {
+172 -1
View File
@@ -45,7 +45,7 @@ fn restored_provider_projection_skips_stream_initialization() {
capabilities: RuntimeCapabilities::provider(), capabilities: RuntimeCapabilities::provider(),
empty_output_message: None, empty_output_message: None,
}; };
let mut projector = ProviderRunResponseProjector::restored(config); let mut projector = ProviderRunResponseProjector::restored(config, true);
let work_id = galaxy_agent_core::ExternalWorkId { let work_id = galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"), run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(2), epoch: galaxy_agent_core::RunEpoch::new(2),
@@ -82,6 +82,56 @@ fn restored_provider_projection_skips_stream_initialization() {
)); ));
} }
#[test]
fn restored_uninitialized_projection_replays_init_before_live_delta() {
let config = RuntimeResponseConfig {
task_id: "task".to_owned(),
conversation_id: "conversation".to_owned(),
needs_create_task: true,
user_query: Some("already persisted".to_owned()),
model_id: "model".to_owned(),
max_context_tokens: Some(1_000),
capabilities: RuntimeCapabilities::provider(),
empty_output_message: None,
};
let mut projector = ProviderRunResponseProjector::restored(config, false);
let work_id = galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(2),
};
let started = projector
.project(ProviderRunProjection::ModelTurnStarted {
work_id: work_id.clone(),
profile: galaxy_agent_core::ProviderRequestProfile::new("base"),
runtime_id: "runtime".to_owned(),
model_id: "model".to_owned(),
runtime_request_id: "request".to_owned(),
retry_attempt: 0,
elapsed_ms: 1,
})
.unwrap();
let delta = projector
.project(ProviderRunProjection::ModelEvent {
work_id,
event: AgentEvent::TextDelta {
text: "continued".to_owned(),
},
})
.unwrap();
assert_eq!(started.len(), 1);
assert!(matches!(
started[0].r#type,
Some(response_event::Type::Init(_))
));
assert_eq!(delta.len(), 1);
assert!(matches!(
delta[0].r#type,
Some(response_event::Type::ClientActions(_))
));
}
#[test] #[test]
fn provider_followup_turn_starts_a_distinct_text_message() { fn provider_followup_turn_starts_a_distinct_text_message() {
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig { let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
@@ -261,6 +311,127 @@ fn reasoning_uses_the_native_reasoning_message_contract() {
)); ));
} }
#[test]
fn reasoning_completed_authoritatively_replaces_streamed_reasoning() {
let mut translator = provider_translator();
let streamed = translator
.translate(AgentEvent::ReasoningDelta {
text: "draft reasoning".to_owned(),
})
.expect("reasoning delta");
let completed = translator
.translate(AgentEvent::ReasoningCompleted {
text: "authoritative reasoning".to_owned(),
signature: Some("signature".to_owned()),
})
.expect("reasoning completion");
let Some(response_event::Type::ClientActions(streamed_actions)) = &streamed[1].r#type else {
panic!("expected streamed reasoning action");
};
let Some(client_action::Action::AddMessagesToTask(add)) = &streamed_actions.actions[0].action
else {
panic!("expected streamed reasoning message");
};
let message_id = add.messages[0].id.clone();
let Some(response_event::Type::ClientActions(completed_actions)) = &completed[0].r#type else {
panic!("expected completed reasoning action");
};
let Some(client_action::Action::UpdateTaskMessage(update)) =
&completed_actions.actions[0].action
else {
panic!("completed reasoning must replace the streamed value");
};
let message = update.message.as_ref().expect("replacement message");
assert_eq!(message.id, message_id);
assert!(matches!(
&message.message,
Some(message::Message::AgentReasoning(reasoning))
if reasoning.reasoning == "authoritative reasoning"
));
assert_eq!(
update.mask.as_ref().expect("replacement mask").paths,
["agent_reasoning.reasoning"]
);
}
#[test]
fn provider_retry_clears_failed_attempt_output_before_new_messages() {
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
task_id: "task".to_owned(),
conversation_id: "conversation".to_owned(),
needs_create_task: false,
user_query: None,
model_id: "model".to_owned(),
max_context_tokens: Some(1_000),
capabilities: RuntimeCapabilities::provider(),
empty_output_message: None,
});
let work_id = galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(1),
};
projector
.project(ProviderRunProjection::ModelTurnStarted {
work_id: work_id.clone(),
profile: galaxy_agent_core::ProviderRequestProfile::new("base"),
runtime_id: "runtime".to_owned(),
model_id: "model".to_owned(),
runtime_request_id: "request".to_owned(),
retry_attempt: 0,
elapsed_ms: 1,
})
.unwrap();
for event in [
AgentEvent::TextDelta {
text: "failed text".to_owned(),
},
AgentEvent::ReasoningDelta {
text: "failed reasoning".to_owned(),
},
] {
projector
.project(ProviderRunProjection::ModelEvent {
work_id: work_id.clone(),
event,
})
.unwrap();
}
let retry = projector
.project(ProviderRunProjection::ModelRetry {
work_id,
profile: galaxy_agent_core::ProviderRequestProfile::new("base"),
runtime_id: "runtime".to_owned(),
model_id: "model".to_owned(),
retry_attempt: 1,
elapsed_ms: 2,
error: galaxy_agent_core::AgentError::new(
galaxy_agent_core::AgentErrorKind::Transport,
"retry",
),
})
.unwrap();
assert_eq!(retry.len(), 2);
for event in retry {
let Some(response_event::Type::ClientActions(actions)) = event.r#type else {
panic!("cleanup must use a client action");
};
let Some(client_action::Action::UpdateTaskMessage(update)) = &actions.actions[0].action
else {
panic!("cleanup must replace failed output");
};
let message = update.message.as_ref().expect("cleanup message");
match &message.message {
Some(message::Message::AgentOutput(output)) => assert!(output.text.is_empty()),
Some(message::Message::AgentReasoning(reasoning)) => {
assert!(reasoning.reasoning.is_empty())
}
_ => panic!("cleanup must target visible text or reasoning"),
}
}
}
#[test] #[test]
fn session_activity_updates_the_same_structured_message() { fn session_activity_updates_the_same_structured_message() {
let mut translator = session_translator(); let mut translator = session_translator();
+276 -135
View File
@@ -5,16 +5,17 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::StreamExt; use futures::{FutureExt as _, StreamExt};
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentRuntime, CompletedModelTurn, ContentPart, turn_control, AgentError, AgentErrorKind, AgentEvent, AgentRuntime, CompletedModelTurn,
ExternalWorkId, ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ContentPart, ExternalWorkId, ModelFailureDisposition, PendingToolBatch, ProviderModelCall,
ProviderRequestProfile, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRequestProfile, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits,
ProviderRunOutcome, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunOutcome, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState,
ProviderRunStep, RunEpoch, RuntimeKind, StopReason, ToolEvent, TurnControl, TurnRequest, Usage, ProviderRunStep, RunEpoch, RuntimeKind, StopReason, ToolEvent, TurnCommand, TurnControl,
TurnRequest, Usage,
}; };
use instant::Instant; use instant::Instant;
use warpui::r#async::FutureExt as _; use warpui::r#async::Timer;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
@@ -321,31 +322,55 @@ impl ProviderRunCoordinator {
&mut self, &mut self,
control: TurnControl, control: TurnControl,
mut project: F, mut project: F,
mut checkpoint: C, checkpoint: C,
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError> ) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
where where
F: FnMut(ProviderRunProjection) -> Result<(), String>, F: FnMut(ProviderRunProjection) -> Result<(), String>,
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>, C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
{
self.drive_until_blocked_with_acknowledgements(
control,
move |projection| {
let result = project(projection);
Box::pin(async move { result })
},
checkpoint,
)
.await
}
pub(crate) async fn drive_until_blocked_with_acknowledgements<F, C>(
&mut self,
control: TurnControl,
mut project: F,
mut checkpoint: C,
) -> Result<ProviderRunBlock, ProviderRunCoordinatorError>
where
F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
C: FnMut(ProviderRun) -> BoxFuture<'static, Result<(), String>>,
{ {
loop { loop {
match self.run.next_step()? { match self.run.next_step()? {
Some(ProviderRunStep::CallModel(call)) => { Some(ProviderRunStep::CallModel(call)) => {
if !self.checkpoint_or_fail(&mut checkpoint).await? { if !self.checkpoint_or_fail(&mut checkpoint).await? {
continue; return self.terminal_block();
} }
self.drive_model_call(call, control.clone(), &mut project) self.drive_model_call_acknowledged(call, control.clone(), &mut project)
.await?; .await?;
} }
Some(ProviderRunStep::DispatchTools(batch)) => { Some(ProviderRunStep::DispatchTools(batch)) => {
if !self.checkpoint_or_fail(&mut checkpoint).await? { if !self.checkpoint_or_fail(&mut checkpoint).await? {
continue; return self.terminal_block();
} }
if !self.project_or_fail( if !self
ProviderRunProjection::ToolBatchReady { .project_or_fail_acknowledged(
batch: batch.clone(), ProviderRunProjection::ToolBatchReady {
}, batch: batch.clone(),
&mut project, },
)? { &mut project,
)
.await?
{
continue; continue;
} }
if batch.is_complete() { if batch.is_complete() {
@@ -356,7 +381,7 @@ impl ProviderRunCoordinator {
} }
Some(ProviderRunStep::Done(outcome)) => { Some(ProviderRunStep::Done(outcome)) => {
if !self.checkpoint_or_fail(&mut checkpoint).await? { if !self.checkpoint_or_fail(&mut checkpoint).await? {
continue; return self.terminal_block();
} }
return Ok(ProviderRunBlock::Done(outcome)); return Ok(ProviderRunBlock::Done(outcome));
} }
@@ -368,7 +393,7 @@ impl ProviderRunCoordinator {
let work_id = work_id.clone(); let work_id = work_id.clone();
let stop_reason = stop_reason.clone(); let stop_reason = stop_reason.clone();
if !self.checkpoint_or_fail(&mut checkpoint).await? { if !self.checkpoint_or_fail(&mut checkpoint).await? {
continue; return self.terminal_block();
} }
return Ok(ProviderRunBlock::AwaitingDriver { return Ok(ProviderRunBlock::AwaitingDriver {
work_id, work_id,
@@ -413,14 +438,26 @@ impl ProviderRunCoordinator {
} }
} }
async fn drive_model_call<F>( fn terminal_block(&mut self) -> Result<ProviderRunBlock, ProviderRunCoordinatorError> {
match self.run.next_step()? {
Some(ProviderRunStep::Done(outcome)) => Ok(ProviderRunBlock::Done(outcome)),
Some(ProviderRunStep::CallModel(_) | ProviderRunStep::DispatchTools(_)) | None => Err(
ProviderRunCoordinatorError::Core(ProviderRunProtocolError::UnexpectedState {
expected: ProviderRunPhase::Failed,
actual: self.run.state().phase(),
}),
),
}
}
async fn drive_model_call_acknowledged<F>(
&mut self, &mut self,
call: ProviderModelCall, call: ProviderModelCall,
control: TurnControl, control: TurnControl,
project: &mut F, project: &mut F,
) -> Result<(), ProviderRunCoordinatorError> ) -> Result<(), ProviderRunCoordinatorError>
where where
F: FnMut(ProviderRunProjection) -> Result<(), String>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
let Some(profile) = self.profiles.get(call.profile.as_str()).cloned() else { let Some(profile) = self.profiles.get(call.profile.as_str()).cloned() else {
self.run.fail( self.run.fail(
@@ -440,39 +477,66 @@ impl ProviderRunCoordinator {
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let runtime_id = profile.runtime.descriptor().id.clone(); let runtime_id = profile.runtime.descriptor().id.clone();
let model_id = profile.request.model.as_str().to_string(); let model_id = profile.request.model.as_str().to_string();
if !self.project_or_fail( if !self
ProviderRunProjection::ModelTurnRequested { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelTurnRequested {
profile: call.profile.clone(), work_id: call.work_id.clone(),
runtime_id: runtime_id.clone(), profile: call.profile.clone(),
model_id: model_id.clone(), runtime_id: runtime_id.clone(),
retry_attempt: call.retry_attempt, model_id: model_id.clone(),
}, retry_attempt: call.retry_attempt,
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
let request = request_for_model_call(profile.request.clone(), &call); let request = request_for_model_call(profile.request.clone(), &call);
let started_at = Instant::now(); let started_at = Instant::now();
let stream = match profile let (attempt_sender, attempt_control) = turn_control();
.runtime let start_future = profile.runtime.start_turn(request, attempt_control).fuse();
.start_turn(request, control) let timeout = futures::FutureExt::fuse(Timer::after(self.model_start_timeout));
.with_timeout(self.model_start_timeout) futures::pin_mut!(start_future, timeout);
.await let mut control_open = true;
{ let start_result = loop {
Ok(Ok(stream)) => stream, let command = if control_open {
Ok(Err(error)) => { futures::future::Either::Left(control.receive())
self.handle_model_failure(&call, &profile, started_at, error, project)?; } else {
futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(command);
futures::select_biased! {
result = start_future => break Some(result),
_ = timeout => {
let _ = attempt_sender.send(TurnCommand::Cancel).await;
break None;
}
command = command => match command {
Ok(command) => {
let _ = attempt_sender.send(command).await;
}
Err(_) => control_open = false,
},
}
};
let stream = match start_result {
Some(Ok(stream)) => stream,
Some(Err(error)) => {
self.handle_model_failure_acknowledged(&call, &profile, started_at, error, project)
.await?;
return Ok(()); return Ok(());
} }
Err(_) => { None => {
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
provider_timeout_error("start", self.model_start_timeout), provider_timeout_error("start", self.model_start_timeout),
project, project,
)?; )
.await?;
return Ok(()); return Ok(());
} }
}; };
@@ -480,157 +544,230 @@ impl ProviderRunCoordinator {
let mut buffer = ModelTurnBuffer::default(); let mut buffer = ModelTurnBuffer::default();
loop { loop {
let event = match stream let next_event = stream.next().fuse();
.next() let timeout = futures::FutureExt::fuse(Timer::after(self.model_event_idle_timeout));
.with_timeout(self.model_event_idle_timeout) futures::pin_mut!(next_event, timeout);
.await let event_result = loop {
{ let command = if control_open {
Ok(Some(Ok(event))) => event, futures::future::Either::Left(control.receive())
Ok(Some(Err(error))) => { } else {
self.handle_model_failure(&call, &profile, started_at, error, project)?; futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(command);
futures::select_biased! {
event = next_event => break Some(event),
_ = timeout => {
let _ = attempt_sender.send(TurnCommand::Cancel).await;
break None;
}
command = command => match command {
Ok(command) => {
let _ = attempt_sender.send(command).await;
}
Err(_) => control_open = false,
},
}
};
let event = match event_result {
Some(Some(Ok(event))) => event,
Some(Some(Err(error))) => {
self.handle_model_failure_acknowledged(
&call, &profile, started_at, error, project,
)
.await?;
return Ok(()); return Ok(());
} }
Ok(None) => break, Some(None) => break,
Err(_) => { None => {
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
provider_timeout_error("event", self.model_event_idle_timeout), provider_timeout_error("event", self.model_event_idle_timeout),
project, project,
)?; )
.await?;
return Ok(()); return Ok(());
} }
}; };
match event { match event {
AgentEvent::TurnStarted { runtime_request_id } => { AgentEvent::TurnStarted { runtime_request_id } => {
if buffer.started { if buffer.started {
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
protocol_error("provider emitted more than one TurnStarted event"), protocol_error("provider emitted more than one TurnStarted event"),
project, project,
)?; )
.await?;
return Ok(()); return Ok(());
} }
if runtime_request_id.is_empty() { if runtime_request_id.is_empty() {
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
protocol_error("provider emitted an empty runtime request ID"), protocol_error("provider emitted an empty runtime request ID"),
project, project,
)?; )
.await?;
return Ok(()); return Ok(());
} }
buffer.started = true; buffer.started = true;
if !self.project_or_fail( if !self
ProviderRunProjection::ModelTurnStarted { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelTurnStarted {
profile: call.profile.clone(), work_id: call.work_id.clone(),
runtime_id: runtime_id.clone(), profile: call.profile.clone(),
model_id: model_id.clone(), runtime_id: runtime_id.clone(),
runtime_request_id, model_id: model_id.clone(),
retry_attempt: call.retry_attempt, runtime_request_id,
elapsed_ms: elapsed_millis(started_at), retry_attempt: call.retry_attempt,
}, elapsed_ms: elapsed_millis(started_at),
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
} }
AgentEvent::TextDelta { text } => { AgentEvent::TextDelta { text } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
buffer.text.push_str(&text); buffer.text.push_str(&text);
if !self.project_or_fail( if !self
ProviderRunProjection::ModelEvent { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelEvent {
event: AgentEvent::TextDelta { text }, work_id: call.work_id.clone(),
}, event: AgentEvent::TextDelta { text },
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
} }
AgentEvent::ReasoningDelta { text } => { AgentEvent::ReasoningDelta { text } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
buffer.reasoning.push_str(&text); buffer.reasoning.push_str(&text);
if !self.project_or_fail( if !self
ProviderRunProjection::ModelEvent { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelEvent {
event: AgentEvent::ReasoningDelta { text }, work_id: call.work_id.clone(),
}, event: AgentEvent::ReasoningDelta { text },
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
} }
AgentEvent::ReasoningCompleted { text, signature } => { AgentEvent::ReasoningCompleted { text, signature } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
if !text.is_empty() { buffer.reasoning.clone_from(&text);
buffer.reasoning.clone_from(&text);
}
buffer.reasoning_signature.clone_from(&signature); buffer.reasoning_signature.clone_from(&signature);
if !self.project_or_fail( if !self
ProviderRunProjection::ModelEvent { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelEvent {
event: AgentEvent::ReasoningCompleted { text, signature }, work_id: call.work_id.clone(),
}, event: AgentEvent::ReasoningCompleted { text, signature },
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
} }
AgentEvent::Tool { AgentEvent::Tool {
event: ToolEvent::Proposed { call: tool_call }, event: ToolEvent::Proposed { call: tool_call },
} => { } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
buffer.tool_calls.push(tool_call); buffer.tool_calls.push(tool_call);
} }
AgentEvent::UsageUpdated { usage } => { AgentEvent::UsageUpdated { usage } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
buffer.usage.clone_from(&usage); buffer.usage.clone_from(&usage);
let cumulative_usage = combined_usage(self.run.usage(), &usage); let cumulative_usage = combined_usage(self.run.usage(), &usage);
if !self.project_or_fail( if !self
ProviderRunProjection::ModelEvent { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelEvent {
event: AgentEvent::UsageUpdated { work_id: call.work_id.clone(),
usage: cumulative_usage, event: AgentEvent::UsageUpdated {
usage: cumulative_usage,
},
}, },
}, project,
project, )
)? { .await?
{
return Ok(()); return Ok(());
} }
} }
AgentEvent::TurnStopped { reason } => { AgentEvent::TurnStopped { reason } => {
if !self.ensure_model_started(&call, &profile, started_at, &buffer, project)? { if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(()); return Ok(());
} }
if !self.project_or_fail( if !self
ProviderRunProjection::ModelTurnFinished { .project_or_fail_acknowledged(
work_id: call.work_id.clone(), ProviderRunProjection::ModelTurnFinished {
profile: call.profile.clone(), work_id: call.work_id.clone(),
runtime_id: runtime_id.clone(), profile: call.profile.clone(),
model_id: model_id.clone(), runtime_id: runtime_id.clone(),
stop_reason: reason.clone(), model_id: model_id.clone(),
retry_attempt: call.retry_attempt, stop_reason: reason.clone(),
elapsed_ms: elapsed_millis(started_at), retry_attempt: call.retry_attempt,
tool_call_count: buffer.tool_calls.len(), elapsed_ms: elapsed_millis(started_at),
}, tool_call_count: buffer.tool_calls.len(),
project, },
)? { project,
)
.await?
{
return Ok(()); return Ok(());
} }
if reason == StopReason::Cancelled { if reason == StopReason::Cancelled {
@@ -657,7 +794,7 @@ impl ProviderRunCoordinator {
| AgentEvent::ContextUsageUpdated { .. } | AgentEvent::ContextUsageUpdated { .. }
| AgentEvent::UserInputAccepted { .. } | AgentEvent::UserInputAccepted { .. }
| AgentEvent::RuntimeNotice { .. } => { | AgentEvent::RuntimeNotice { .. } => {
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
@@ -665,23 +802,25 @@ impl ProviderRunCoordinator {
"direct-provider transport emitted a non-model lifecycle event", "direct-provider transport emitted a non-model lifecycle event",
), ),
project, project,
)?; )
.await?;
return Ok(()); return Ok(());
} }
} }
} }
self.handle_model_failure( self.handle_model_failure_acknowledged(
&call, &call,
&profile, &profile,
started_at, started_at,
protocol_error("provider stream ended before TurnStopped"), protocol_error("provider stream ended before TurnStopped"),
project, project,
)?; )
.await?;
Ok(()) Ok(())
} }
fn ensure_model_started<F>( async fn ensure_model_started_acknowledged<F>(
&mut self, &mut self,
call: &ProviderModelCall, call: &ProviderModelCall,
profile: &ProviderRunProfile, profile: &ProviderRunProfile,
@@ -690,22 +829,23 @@ impl ProviderRunCoordinator {
project: &mut F, project: &mut F,
) -> Result<bool, ProviderRunCoordinatorError> ) -> Result<bool, ProviderRunCoordinatorError>
where where
F: FnMut(ProviderRunProjection) -> Result<(), String>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
if buffer.started { if buffer.started {
return Ok(true); return Ok(true);
} }
self.handle_model_failure( self.handle_model_failure_acknowledged(
call, call,
profile, profile,
started_at, started_at,
protocol_error("provider emitted model output before TurnStarted"), protocol_error("provider emitted model output before TurnStarted"),
project, project,
)?; )
.await?;
Ok(false) Ok(false)
} }
fn handle_model_failure<F>( async fn handle_model_failure_acknowledged<F>(
&mut self, &mut self,
call: &ProviderModelCall, call: &ProviderModelCall,
profile: &ProviderRunProfile, profile: &ProviderRunProfile,
@@ -714,7 +854,7 @@ impl ProviderRunCoordinator {
project: &mut F, project: &mut F,
) -> Result<(), ProviderRunCoordinatorError> ) -> Result<(), ProviderRunCoordinatorError>
where where
F: FnMut(ProviderRunProjection) -> Result<(), String>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
let disposition = self let disposition = self
.run .run
@@ -737,7 +877,7 @@ impl ProviderRunCoordinator {
)); ));
} }
}; };
self.project_or_fail( self.project_or_fail_acknowledged(
ProviderRunProjection::ModelRetry { ProviderRunProjection::ModelRetry {
work_id: call.work_id.clone(), work_id: call.work_id.clone(),
profile: call.profile.clone(), profile: call.profile.clone(),
@@ -748,20 +888,21 @@ impl ProviderRunCoordinator {
error, error,
}, },
project, project,
)?; )
.await?;
} }
Ok(()) Ok(())
} }
fn project_or_fail<F>( async fn project_or_fail_acknowledged<F>(
&mut self, &mut self,
event: ProviderRunProjection, event: ProviderRunProjection,
project: &mut F, project: &mut F,
) -> Result<bool, ProviderRunCoordinatorError> ) -> Result<bool, ProviderRunCoordinatorError>
where where
F: FnMut(ProviderRunProjection) -> Result<(), String>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
match project(event) { match project(event).await {
Ok(()) => Ok(true), Ok(()) => Ok(true),
Err(message) => { Err(message) => {
self.run.fail( self.run.fail(
@@ -79,6 +79,7 @@ struct StallingRuntime {
first_attempt_stall: FirstAttemptStall, first_attempt_stall: FirstAttemptStall,
attempts: AtomicUsize, attempts: AtomicUsize,
requests: Mutex<Vec<TurnRequest>>, requests: Mutex<Vec<TurnRequest>>,
controls: Mutex<Vec<TurnControl>>,
} }
impl StallingRuntime { impl StallingRuntime {
@@ -93,12 +94,22 @@ impl StallingRuntime {
first_attempt_stall, first_attempt_stall,
attempts: AtomicUsize::new(0), attempts: AtomicUsize::new(0),
requests: Mutex::new(Vec::new()), requests: Mutex::new(Vec::new()),
controls: Mutex::new(Vec::new()),
} }
} }
fn requests(&self) -> Vec<TurnRequest> { fn requests(&self) -> Vec<TurnRequest> {
self.requests.lock().unwrap().clone() self.requests.lock().unwrap().clone()
} }
fn attempt_commands(&self) -> Vec<Option<TurnCommand>> {
self.controls
.lock()
.unwrap()
.iter()
.map(|control| control.try_receive().ok())
.collect()
}
} }
#[async_trait] #[async_trait]
@@ -110,9 +121,10 @@ impl AgentRuntime for StallingRuntime {
async fn start_turn( async fn start_turn(
&self, &self,
request: TurnRequest, request: TurnRequest,
_control: TurnControl, control: TurnControl,
) -> Result<AgentEventStream, AgentError> { ) -> Result<AgentEventStream, AgentError> {
self.requests.lock().unwrap().push(request); self.requests.lock().unwrap().push(request);
self.controls.lock().unwrap().push(control);
let attempt = self.attempts.fetch_add(1, Ordering::Relaxed); let attempt = self.attempts.fetch_add(1, Ordering::Relaxed);
if attempt == 0 { if attempt == 0 {
match self.first_attempt_stall { match self.first_attempt_stall {
@@ -778,6 +790,64 @@ async fn recoverable_start_failure_retries_the_same_work_identity() {
assert_eq!(retry.1, 1); assert_eq!(retry.1, 1);
} }
#[tokio::test]
async fn model_progress_waits_for_each_projection_acknowledgement() {
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
let mut coordinator = coordinator(runtime.clone());
let (acknowledgement_sender, acknowledgement_receiver) = async_channel::unbounded();
let (observed_sender, observed_receiver) = async_channel::unbounded();
let (_sender, control) = turn_control();
let drive = async {
coordinator
.drive_until_blocked_with_acknowledgements(
control,
move |projection| {
let acknowledgement_receiver = acknowledgement_receiver.clone();
let observed_sender = observed_sender.clone();
Box::pin(async move {
observed_sender.send(projection).await.unwrap();
acknowledgement_receiver.recv().await.unwrap()
})
},
|_| Box::pin(async { Ok(()) }),
)
.await
.unwrap()
};
let driver = async {
let first = observed_receiver.recv().await.unwrap();
assert!(matches!(
first,
ProviderRunProjection::ModelTurnRequested { .. }
));
assert_eq!(runtime.requests().len(), 0);
assert!(observed_receiver.try_recv().is_err());
acknowledgement_sender.send(Ok(())).await.unwrap();
let second = observed_receiver.recv().await.unwrap();
assert!(matches!(
second,
ProviderRunProjection::ModelTurnStarted { .. }
));
assert_eq!(runtime.requests().len(), 1);
assert!(observed_receiver.try_recv().is_err());
acknowledgement_sender.send(Ok(())).await.unwrap();
loop {
let projection = observed_receiver.recv().await.unwrap();
let finished = matches!(projection, ProviderRunProjection::ModelTurnFinished { .. });
acknowledgement_sender.send(Ok(())).await.unwrap();
if finished {
break;
}
}
};
let (block, ()) = futures::join!(drive, driver);
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
}
fn assert_single_retry_lifecycle( fn assert_single_retry_lifecycle(
projections: &[ProviderRunProjection], projections: &[ProviderRunProjection],
expected_timeout_stage: &str, expected_timeout_stage: &str,
@@ -868,6 +938,10 @@ async fn model_start_timeout_retries_the_same_work_identity() {
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. })); assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
assert_eq!(runtime.requests().len(), 2); assert_eq!(runtime.requests().len(), 2);
assert_eq!(
runtime.attempt_commands(),
vec![Some(TurnCommand::Cancel), None]
);
assert_eq!(coordinator.run().model_retries(), 1); assert_eq!(coordinator.run().model_retries(), 1);
assert_single_retry_lifecycle(&projections, "start timed out", false); assert_single_retry_lifecycle(&projections, "start timed out", false);
} }
@@ -887,10 +961,43 @@ async fn model_event_idle_timeout_retries_the_same_work_identity() {
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. })); assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
assert_eq!(runtime.requests().len(), 2); assert_eq!(runtime.requests().len(), 2);
assert_eq!(
runtime.attempt_commands(),
vec![Some(TurnCommand::Cancel), None]
);
assert_eq!(coordinator.run().model_retries(), 1); assert_eq!(coordinator.run().model_retries(), 1);
assert_single_retry_lifecycle(&projections, "event timed out", true); assert_single_retry_lifecycle(&projections, "event timed out", true);
} }
#[tokio::test]
async fn persistent_checkpoint_failure_terminates_without_redrive() {
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
let mut coordinator = coordinator(runtime.clone());
let checkpoint_attempts = Arc::new(AtomicUsize::new(0));
let attempts = checkpoint_attempts.clone();
let (_sender, control) = turn_control();
let block = coordinator
.drive_until_blocked_with_checkpoint(
control,
|_| Ok(()),
move |_| {
attempts.fetch_add(1, Ordering::Relaxed);
Box::pin(async { Err("database unavailable".to_string()) })
},
)
.await
.unwrap();
let ProviderRunBlock::Done(ProviderRunOutcome::Failed(failure)) = block else {
panic!("checkpoint failure must terminate the drive");
};
assert_eq!(failure.kind, ProviderRunFailureKind::ExternalWork);
assert!(failure.message.contains("database unavailable"));
assert_eq!(checkpoint_attempts.load(Ordering::Relaxed), 1);
assert!(runtime.requests().is_empty());
}
#[tokio::test] #[tokio::test]
async fn tool_projection_failure_synthesizes_a_result_and_fails_the_run() { async fn tool_projection_failure_synthesizes_a_result_and_fails_the_run() {
let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn()])); let runtime = Arc::new(ScriptedRuntime::new(vec![tool_turn()]));
@@ -953,6 +1060,38 @@ async fn stream_without_terminal_event_fails_instead_of_committing_partial_outpu
assert_eq!(coordinator.run().transcript().len(), 1); assert_eq!(coordinator.run().transcript().len(), 1);
} }
#[tokio::test]
async fn reasoning_completed_authoritatively_replaces_canonical_reasoning() {
let runtime = Arc::new(ScriptedRuntime::new(vec![Ok(vec![
started("request-reasoning"),
Ok(AgentEvent::ReasoningDelta {
text: "draft".to_string(),
}),
Ok(AgentEvent::ReasoningCompleted {
text: "final".to_string(),
signature: Some("signature".to_string()),
}),
stopped(StopReason::Completed),
])]));
let mut coordinator = coordinator(runtime);
let (_sender, control) = turn_control();
let block = coordinator
.drive_until_blocked(control, |_| Ok(()))
.await
.unwrap();
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
let MessageContent::MultiPart(parts) = &coordinator.run().transcript()[1].content else {
panic!("expected canonical reasoning content");
};
assert!(matches!(
parts.as_slice(),
[ContentPart::Reasoning { text, signature }]
if text == "final" && signature.as_deref() == Some("signature")
));
}
#[tokio::test] #[tokio::test]
async fn provider_cancellation_does_not_commit_partial_assistant_content() { async fn provider_cancellation_does_not_commit_partial_assistant_content() {
let expected_transcript = request().messages; let expected_transcript = request().messages;
+10
View File
@@ -835,6 +835,16 @@ fn build_system_prompt(
.join(", "), .join(", "),
); );
prompt.push_str(".\nNever invent tool names or parameters. Check command exit codes and tool error results before claiming success.\n"); prompt.push_str(".\nNever invent tool names or parameters. Check command exit codes and tool error results before claiming success.\n");
if tools.iter().any(|tool| tool.name == "run_shell_command") {
let has_file_tools = tools
.iter()
.any(|tool| matches!(tool.name.as_str(), "file_glob" | "grep" | "read_files"));
if has_file_tools {
prompt.push_str(
"Prefer `file_glob`, `grep`, and `read_files` for file discovery, content search, and file reading when they are available. Reserve `run_shell_command` for operations those specialized tools cannot perform; do not use shell `find`, `grep`, `rg`, `cat`, `head`, or `tail` as substitutes.\n",
);
}
}
if tools.iter().any(|tool| tool.name == "create_plan") { if tools.iter().any(|tool| tool.name == "create_plan") {
prompt.push_str( prompt.push_str(
"Plan document creation is available through `create_plan`. When the user asks you to create a plan for review, research first as needed, then call `create_plan`; do not merely return the plan as prose or claim that no plan-creation tool is available. If the user asks to review the plan before implementation, creating the document and presenting it for review is the requested outcome; do not implement it until they approve.\n", "Plan document creation is available through `create_plan`. When the user asks you to create a plan for review, research first as needed, then call `create_plan`; do not merely return the plan as prose or claim that no plan-creation tool is available. If the user asks to review the plan before implementation, creating the document and presenting it for review is the requested outcome; do not implement it until they approve.\n",
+22
View File
@@ -127,6 +127,28 @@ fn builds_a_rig_turn_directly_from_galaxy_request_state() {
)); ));
} }
#[test]
fn system_prompt_prefers_specialized_file_tools_over_shell_substitutes() {
let mut params = RequestParams::new_for_test();
params.input = vec![user_query("Find every Rust file containing ProviderRun")];
let prepared = prepare_rig_turn(
&config(),
params,
vec![
ToolType::RunShellCommand,
ToolType::FileGlob,
ToolType::Grep,
ToolType::ReadFiles,
],
Vec::new(),
);
let prompt = prepared.request.system_prompt.expect("system prompt");
assert!(prompt.contains("Prefer `file_glob`, `grep`, and `read_files`"));
assert!(prompt.contains("do not use shell `find`, `grep`, `rg`, `cat`, `head`, or `tail`"));
}
#[test] #[test]
fn normal_turn_advertises_plan_creation_and_corrects_false_unavailability_claims() { fn normal_turn_advertises_plan_creation_and_corrects_false_unavailability_claims() {
let mut params = RequestParams::new_for_test(); let mut params = RequestParams::new_for_test();
+479 -299
View File
@@ -25,6 +25,9 @@ pub(crate) fn action_from_tool_call(
mcp_tool_aliases: &HashMap<String, MCPToolTarget>, mcp_tool_aliases: &HashMap<String, MCPToolTarget>,
) -> Result<AIAgentAction, String> { ) -> Result<AIAgentAction, String> {
let input = &call.arguments; let input = &call.arguments;
if !input.is_object() {
return Err(format!("invalid {} input: expected an object", call.name));
}
let action = if let Some(target) = mcp_tool_aliases.get(&call.name) { let action = if let Some(target) = mcp_tool_aliases.get(&call.name) {
AIAgentActionType::CallMCPTool { AIAgentActionType::CallMCPTool {
server_id: target.server_id, server_id: target.server_id,
@@ -33,197 +36,220 @@ pub(crate) fn action_from_tool_call(
} }
} else { } else {
match call.name.as_str() { match call.name.as_str() {
"run_shell_command" => AIAgentActionType::RequestCommandOutput { "run_shell_command" => AIAgentActionType::RequestCommandOutput {
command: string(input, "command"), command: required_nonempty_string(input, "command")?,
is_read_only: Some(boolean(input, "is_read_only")), is_read_only: Some(optional_boolean(input, "is_read_only")?.unwrap_or(false)),
is_risky: Some(boolean(input, "is_risky")), is_risky: Some(optional_boolean(input, "is_risky")?.unwrap_or(false)),
wait_until_completion: boolean(input, "wait_until_complete"), wait_until_completion: optional_boolean(input, "wait_until_complete")?
uses_pager: Some(boolean(input, "uses_pager")), .unwrap_or(false),
rationale: None, uses_pager: Some(optional_boolean(input, "uses_pager")?.unwrap_or(false)),
citations: Vec::new(), rationale: None,
}, citations: Vec::new(),
"read_files" => AIAgentActionType::ReadFiles(ReadFilesRequest { },
locations: input "read_files" => AIAgentActionType::ReadFiles(ReadFilesRequest {
.get("files") locations: required_array(input, "files")?
.and_then(serde_json::Value::as_array) .iter()
.into_iter() .enumerate()
.flatten() .map(|(index, file)| file_location(file, index))
.filter_map(file_location) .collect::<Result<_, _>>()?,
.collect(), }),
}), "apply_file_diffs" => AIAgentActionType::RequestFileEdits {
"apply_file_diffs" => AIAgentActionType::RequestFileEdits { file_edits: file_edits(input)?,
file_edits: file_edits(input), title: Some(required_string(input, "summary")?),
title: nonempty_string(input, "summary"), },
}, "grep" => AIAgentActionType::Grep {
"grep" => AIAgentActionType::Grep { queries: required_strings(input, "queries")?,
queries: strings(input, "queries"), path: optional_string(input, "path")?.unwrap_or_default(),
path: string(input, "path"), },
}, "file_glob" => AIAgentActionType::FileGlob {
"file_glob" => AIAgentActionType::FileGlob { patterns: required_strings(input, "patterns")?,
patterns: strings(input, "patterns"), path: optional_string(input, "path")?.filter(|path| !path.is_empty()),
path: nonempty_string(input, "path"), },
}, "search_codebase" => AIAgentActionType::SearchCodebase(SearchCodebaseRequest {
"search_codebase" => AIAgentActionType::SearchCodebase(SearchCodebaseRequest { query: required_string(input, "query")?,
query: string(input, "query"), partial_paths: optional_strings(input, "path_filters")?
partial_paths: nonempty_strings(input, "path_filters"), .filter(|paths| !paths.is_empty()),
codebase_path: nonempty_string(input, "path"), codebase_path: optional_string(input, "path")?.filter(|path| !path.is_empty()),
}), }),
"write_to_long_running_shell_command" => { "write_to_long_running_shell_command" => {
AIAgentActionType::WriteToLongRunningShellCommand { AIAgentActionType::WriteToLongRunningShellCommand {
block_id: string(input, "command_id").into(), block_id: required_nonempty_string(input, "command_id")?.into(),
input: string(input, "input").into_bytes().into(), input: required_string(input, "input")?.into_bytes().into(),
mode: match input.get("mode").and_then(serde_json::Value::as_str) { mode: match optional_string(input, "mode")?.as_deref() {
Some("line") => AIAgentPtyWriteMode::Line, Some("line") => AIAgentPtyWriteMode::Line,
Some("block") => AIAgentPtyWriteMode::Block, Some("block") => AIAgentPtyWriteMode::Block,
Some("raw") | Some(_) | None => AIAgentPtyWriteMode::Raw, Some("raw") | None => AIAgentPtyWriteMode::Raw,
}, Some(mode) => {
return Err(format!(
"invalid field \"mode\": expected \"raw\", \"line\", or \"block\", got {mode:?}"
));
}
},
}
} }
} "interrupt_shell_command" => AIAgentActionType::WriteToLongRunningShellCommand {
"interrupt_shell_command" => AIAgentActionType::WriteToLongRunningShellCommand { block_id: required_nonempty_string(input, "command_id")?.into(),
block_id: string(input, "command_id").into(), input: vec![galaxy_terminal::model::escape_sequences::C0::ETX].into(),
input: vec![galaxy_terminal::model::escape_sequences::C0::ETX].into(), mode: AIAgentPtyWriteMode::Raw,
mode: AIAgentPtyWriteMode::Raw, },
}, "read_shell_command_output" => AIAgentActionType::ReadShellCommandOutput {
"read_shell_command_output" => AIAgentActionType::ReadShellCommandOutput { block_id: required_nonempty_string(input, "command_id")?.into(),
block_id: string(input, "command_id").into(), delay: Some(ShellCommandDelay::Duration(Duration::from_secs(
delay: Some(ShellCommandDelay::Duration(Duration::from_secs( optional_bounded_u64(
input input,
.get("wait_seconds") "wait_seconds",
.and_then(serde_json::Value::as_u64) crate::ai::bedrock::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS,
.unwrap_or(2) )?
.min(crate::ai::bedrock::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS), .unwrap_or(2),
))), ))),
}, },
"read_mcp_resource" => AIAgentActionType::ReadMCPResource { "read_mcp_resource" => AIAgentActionType::ReadMCPResource {
server_id: uuid(input, "server_id"), server_id: Some(required_uuid(input, "server_id")?),
name: String::new(), name: String::new(),
uri: nonempty_string(input, "uri"), uri: Some(required_string(input, "uri")?),
}, },
"read_plan" | "read_documents" | "read_notebook" => { "read_plan" | "read_documents" | "read_notebook" => {
AIAgentActionType::ReadDocuments(ReadDocumentsRequest { AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
document_ids: strings(input, "document_ids") document_ids: required_strings(input, "document_ids")?
.into_iter()
.filter_map(|id| AIDocumentId::try_from(id).ok())
.collect(),
})
}
"create_plan" | "create_documents" | "create_notebook" => {
AIAgentActionType::CreateDocuments(CreateDocumentsRequest {
documents: input
.get("documents")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|document| {
Some(DocumentToCreate {
title: document.get("title")?.as_str()?.to_string(),
content: document.get("content")?.as_str()?.to_string(),
})
})
.collect(),
})
}
"edit_plan" | "edit_documents" | "edit_notebook" => {
AIAgentActionType::EditDocuments(EditDocumentsRequest {
diffs: input
.get("diffs")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|diff| {
Some(DocumentDiff {
document_id: AIDocumentId::try_from(diff.get("document_id")?.as_str()?)
.ok()?,
search: string(diff, "search"),
replace: string(diff, "replace"),
})
})
.collect(),
})
}
"run_agents" => AIAgentActionType::RunAgents(RunAgentsRequest {
summary: string(input, "summary"),
base_prompt: string(input, "base_prompt"),
skills: skill_references(input, skill_path_origin),
model_id: string(input, "model_id"),
harness_type: string(input, "harness_type"),
execution_mode: run_agents_execution_mode(input),
agent_run_configs: input
.get("agent_run_configs")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|config| RunAgentsAgentRunConfig {
name: string(config, "name"),
prompt: string(config, "prompt"),
title: string(config, "title"),
})
.collect(),
plan_id: string(input, "plan_id"),
harness_auth_secret_name: None,
}),
"start_agent" => AIAgentActionType::StartAgent {
version: StartAgentVersion::V1,
name: string(input, "name"),
prompt: string(input, "prompt"),
execution_mode: StartAgentExecutionMode::local_with_defaults(),
lifecycle_subscription: None,
},
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
addresses: vec![string(input, "agent_id")],
subject: String::new(),
message: string(input, "message"),
},
"ask_user_question" => AIAgentActionType::AskUserQuestion {
questions: vec![AskUserQuestionItem {
question_id: Uuid::new_v4().to_string(),
question: string(input, "question"),
question_type: AskUserQuestionType::MultipleChoice {
is_multiselect: false,
options: strings(input, "options")
.into_iter() .into_iter()
.enumerate() .map(|id| {
.map(|(index, label)| AskUserQuestionOption { AIDocumentId::try_from(id.clone()).map_err(|_| {
label, format!("invalid document_ids entry: {id:?} is not a document ID")
recommended: index == 0, })
}) })
.collect(), .collect::<Result<_, _>>()?,
supports_other: true, })
}, }
}], "create_plan" | "create_documents" | "create_notebook" => {
}, AIAgentActionType::CreateDocuments(CreateDocumentsRequest {
"read_skill" => { documents: required_array(input, "documents")?
let skill = string(input, "skill"); .iter()
let skill = match input .enumerate()
.get("reference_type") .map(|(index, document)| {
.and_then(serde_json::Value::as_str) require_object(document, &format!("documents[{index}]"))?;
{ Ok(DocumentToCreate {
Some("bundled") => SkillReference::BundledSkillId(skill), title: required_string(document, "title")?,
Some("path") | Some(_) | None => SkillReference::Path( content: required_string(document, "content")?,
skill_path_origin })
.location_for_path(skill) })
.map_err(|error| error.to_string())?, .collect::<Result<_, String>>()?,
), })
}; }
AIAgentActionType::ReadSkill(ReadSkillRequest { skill }) "edit_plan" | "edit_documents" | "edit_notebook" => {
} AIAgentActionType::EditDocuments(EditDocumentsRequest {
"fetch_conversation" => AIAgentActionType::FetchConversation { diffs: required_array(input, "diffs")?
conversation_id: string(input, "conversation_id"), .iter()
}, .enumerate()
name if name.starts_with("mcp__") => { .map(|(index, diff)| {
let mut parts = name.splitn(3, "__"); require_object(diff, &format!("diffs[{index}]"))?;
let _prefix = parts.next(); let document_id = required_string(diff, "document_id")?;
let server_id = parts.next().and_then(|value| Uuid::parse_str(value).ok()); Ok(DocumentDiff {
let name = parts document_id: AIDocumentId::try_from(document_id.clone())
.next() .map_err(|_| format!("invalid document_id: {document_id:?}"))?,
.unwrap_or_else(|| name.strip_prefix("mcp__").unwrap_or(name)) search: required_string(diff, "search")?,
.to_string(); replace: required_string(diff, "replace")?,
AIAgentActionType::CallMCPTool { })
server_id, })
name, .collect::<Result<_, String>>()?,
input: input.clone(), })
}
"run_agents" => AIAgentActionType::RunAgents(RunAgentsRequest {
summary: required_nonempty_string(input, "summary")?,
base_prompt: optional_string(input, "base_prompt")?.unwrap_or_default(),
skills: skill_references(input, skill_path_origin)?,
model_id: optional_string(input, "model_id")?.unwrap_or_default(),
harness_type: optional_string(input, "harness_type")?.unwrap_or_default(),
execution_mode: run_agents_execution_mode(input)?,
agent_run_configs: nonempty_required_array(input, "agent_run_configs")?
.iter()
.enumerate()
.map(|(index, config)| {
require_object(config, &format!("agent_run_configs[{index}]"))?;
Ok(RunAgentsAgentRunConfig {
name: required_nonempty_string(config, "name")?,
prompt: required_nonempty_string(config, "prompt")?,
title: optional_string(config, "title")?.unwrap_or_default(),
})
})
.collect::<Result<_, String>>()?,
plan_id: optional_string(input, "plan_id")?.unwrap_or_default(),
harness_auth_secret_name: None,
}),
"start_agent" => AIAgentActionType::StartAgent {
version: StartAgentVersion::V1,
name: required_nonempty_string(input, "name")?,
prompt: required_nonempty_string(input, "prompt")?,
execution_mode: StartAgentExecutionMode::local_with_defaults(),
lifecycle_subscription: None,
},
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
addresses: vec![required_string(input, "agent_id")?],
subject: String::new(),
message: required_string(input, "message")?,
},
"transfer_shell_command_control_to_user" => {
AIAgentActionType::TransferShellCommandControlToUser {
reason: required_nonempty_string(input, "reason")?,
}
}
"wait_for_events" => AIAgentActionType::WaitForEvents {
tool_call_id: call.id.clone(),
idle_timeout_seconds: optional_nonnegative_i32(input, "idle_timeout_seconds")?
.unwrap_or(0),
},
"ask_user_question" => AIAgentActionType::AskUserQuestion {
questions: vec![AskUserQuestionItem {
question_id: Uuid::new_v4().to_string(),
question: required_string(input, "question")?,
question_type: AskUserQuestionType::MultipleChoice {
is_multiselect: false,
options: optional_strings(input, "options")?
.unwrap_or_default()
.into_iter()
.enumerate()
.map(|(index, label)| AskUserQuestionOption {
label,
recommended: index == 0,
})
.collect(),
supports_other: true,
},
}],
},
"read_skill" => {
let skill = required_string(input, "skill")?;
let skill = match required_string(input, "reference_type")?.as_str() {
"bundled" => SkillReference::BundledSkillId(skill),
"path" => SkillReference::Path(
skill_path_origin
.location_for_path(skill)
.map_err(|error| error.to_string())?,
),
reference_type => {
return Err(format!(
"invalid reference_type: expected \"path\" or \"bundled\", got {reference_type:?}"
));
}
};
AIAgentActionType::ReadSkill(ReadSkillRequest { skill })
}
"fetch_conversation" => AIAgentActionType::FetchConversation {
conversation_id: required_string(input, "conversation_id")?,
},
name if name.starts_with("mcp__") => {
let mut parts = name.splitn(3, "__");
let _prefix = parts.next();
let server_id = parts.next().and_then(|value| Uuid::parse_str(value).ok());
let name = parts
.next()
.unwrap_or_else(|| name.strip_prefix("mcp__").unwrap_or(name))
.to_string();
AIAgentActionType::CallMCPTool {
server_id,
name,
input: input.clone(),
}
} }
}
name => return Err(format!("unsupported Rig tool proposal: {name}")), name => return Err(format!("unsupported Rig tool proposal: {name}")),
} }
}; };
@@ -242,155 +268,309 @@ pub(crate) fn action_from_tool_call(
}) })
} }
fn string(input: &serde_json::Value, key: &str) -> String { fn require_object(input: &serde_json::Value, field: &str) -> Result<(), String> {
input input
.get(key) .is_object()
.and_then(serde_json::Value::as_str) .then_some(())
.unwrap_or_default() .ok_or_else(|| format!("invalid {field}: expected an object"))
.to_string()
} }
fn nonempty_string(input: &serde_json::Value, key: &str) -> Option<String> { fn required_string(input: &serde_json::Value, key: &str) -> Result<String, String> {
let value = string(input, key);
(!value.is_empty()).then_some(value)
}
fn boolean(input: &serde_json::Value, key: &str) -> bool {
input input
.get(key) .get(key)
.and_then(serde_json::Value::as_bool) .ok_or_else(|| format!("missing required field {key:?}"))?
.unwrap_or(false) .as_str()
}
fn strings(input: &serde_json::Value, key: &str) -> Vec<String> {
input
.get(key)
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid field {key:?}: expected a string"))
}
fn required_nonempty_string(input: &serde_json::Value, key: &str) -> Result<String, String> {
let value = required_string(input, key)?;
if value.trim().is_empty() {
return Err(format!(
"invalid field {key:?}: expected a non-empty string"
));
}
Ok(value)
}
fn optional_string(input: &serde_json::Value, key: &str) -> Result<Option<String>, String> {
input
.get(key)
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid field {key:?}: expected a string"))
})
.transpose()
}
fn required_array<'a>(
input: &'a serde_json::Value,
key: &str,
) -> Result<&'a Vec<serde_json::Value>, String> {
input
.get(key)
.ok_or_else(|| format!("missing required field {key:?}"))?
.as_array()
.ok_or_else(|| format!("invalid field {key:?}: expected an array"))
}
fn nonempty_required_array<'a>(
input: &'a serde_json::Value,
key: &str,
) -> Result<&'a Vec<serde_json::Value>, String> {
let values = required_array(input, key)?;
if values.is_empty() {
return Err(format!("invalid field {key:?}: expected at least one item"));
}
Ok(values)
}
fn required_strings(input: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
strings_from_array(required_array(input, key)?, key)
}
fn optional_strings(input: &serde_json::Value, key: &str) -> Result<Option<Vec<String>>, String> {
input
.get(key)
.map(|value| {
let values = value
.as_array()
.ok_or_else(|| format!("invalid field {key:?}: expected an array"))?;
strings_from_array(values, key)
})
.transpose()
}
fn strings_from_array(values: &[serde_json::Value], key: &str) -> Result<Vec<String>, String> {
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid {key}[{index}]: expected a string"))
})
.collect() .collect()
} }
fn nonempty_strings(input: &serde_json::Value, key: &str) -> Option<Vec<String>> { fn required_uuid(input: &serde_json::Value, key: &str) -> Result<Uuid, String> {
let values = strings(input, key); let value = required_string(input, key)?;
(!values.is_empty()).then_some(values) Uuid::parse_str(&value).map_err(|_| format!("invalid field {key:?}: expected a UUID"))
} }
fn uuid(input: &serde_json::Value, key: &str) -> Option<Uuid> { fn optional_boolean(input: &serde_json::Value, key: &str) -> Result<Option<bool>, String> {
input input
.get(key) .get(key)
.and_then(serde_json::Value::as_str) .map(|value| {
.and_then(|value| Uuid::parse_str(value).ok()) value
.as_bool()
.ok_or_else(|| format!("invalid field {key:?}: expected a boolean"))
})
.transpose()
}
fn optional_bounded_u64(
input: &serde_json::Value,
key: &str,
maximum: u64,
) -> Result<Option<u64>, String> {
input
.get(key)
.map(|value| {
let value = value
.as_u64()
.ok_or_else(|| format!("invalid field {key:?}: expected a non-negative integer"))?;
if value > maximum {
return Err(format!(
"invalid field {key:?}: expected an integer no greater than {maximum}"
));
}
Ok(value)
})
.transpose()
}
fn optional_nonnegative_i32(input: &serde_json::Value, key: &str) -> Result<Option<i32>, String> {
input
.get(key)
.map(|value| {
value
.as_i64()
.and_then(|value| i32::try_from(value).ok())
.filter(|value| *value >= 0)
.ok_or_else(|| {
format!("invalid field {key:?}: expected a non-negative 32-bit integer")
})
})
.transpose()
} }
fn skill_references( fn skill_references(
input: &serde_json::Value, input: &serde_json::Value,
skill_path_origin: &SkillPathOrigin, skill_path_origin: &SkillPathOrigin,
) -> Vec<SkillReference> { ) -> Result<Vec<SkillReference>, String> {
input let Some(skills) = optional_array(input, "skills")? else {
.get("skills") return Ok(Vec::new());
.and_then(serde_json::Value::as_array) };
.into_iter() skills
.flatten() .iter()
.filter_map(|skill| { .enumerate()
let reference = string(skill, "skill"); .map(|(index, skill)| {
if reference.is_empty() { require_object(skill, &format!("skills[{index}]"))?;
return None; let reference = required_string(skill, "skill")?;
} match required_string(skill, "reference_type")?.as_str() {
match skill "bundled" => Ok(SkillReference::BundledSkillId(reference)),
.get("reference_type") "path" => skill_path_origin
.and_then(serde_json::Value::as_str)
{
Some("bundled") => Some(SkillReference::BundledSkillId(reference)),
Some("path") | Some(_) | None => skill_path_origin
.location_for_path(reference) .location_for_path(reference)
.ok() .map(SkillReference::Path)
.map(SkillReference::Path), .map_err(|error| error.to_string()),
reference_type => Err(format!(
"invalid skills[{index}].reference_type: expected \"path\" or \"bundled\", got {reference_type:?}"
)),
} }
}) })
.collect() .collect()
} }
fn run_agents_execution_mode(input: &serde_json::Value) -> RunAgentsExecutionMode { fn run_agents_execution_mode(input: &serde_json::Value) -> Result<RunAgentsExecutionMode, String> {
let Some(execution_mode) = input.get("execution_mode") else { let Some(execution_mode) = input.get("execution_mode") else {
return RunAgentsExecutionMode::Local; return Ok(RunAgentsExecutionMode::Local);
}; };
let mode_type = execution_mode require_object(execution_mode, "execution_mode")?;
.get("type") match optional_string(execution_mode, "type")?.as_deref() {
.and_then(serde_json::Value::as_str) Some("remote") => Ok(RunAgentsExecutionMode::Remote {
.or_else(|| execution_mode.as_str()); environment_id: optional_string(execution_mode, "environment_id")?.unwrap_or_default(),
match mode_type { worker_host: optional_string(execution_mode, "worker_host")?.unwrap_or_default(),
Some("remote") => RunAgentsExecutionMode::Remote { computer_use_enabled: optional_boolean(execution_mode, "computer_use_enabled")?
environment_id: string(execution_mode, "environment_id"), .unwrap_or(false),
worker_host: string(execution_mode, "worker_host"), }),
computer_use_enabled: boolean(execution_mode, "computer_use_enabled"), Some("local") | None => {
}, optional_string(execution_mode, "environment_id")?;
Some("local") | Some(_) | None => RunAgentsExecutionMode::Local, optional_string(execution_mode, "worker_host")?;
optional_boolean(execution_mode, "computer_use_enabled")?;
Ok(RunAgentsExecutionMode::Local)
}
Some(mode_type) => Err(format!(
"invalid execution_mode.type: expected \"local\" or \"remote\", got {mode_type:?}"
)),
} }
} }
fn file_location(file: &serde_json::Value) -> Option<FileLocations> { fn file_location(file: &serde_json::Value, file_index: usize) -> Result<FileLocations, String> {
if let Some(name) = file.as_str() { if let Some(name) = file.as_str() {
return Some(FileLocations { return Ok(FileLocations {
name: name.to_string(), name: name.to_string(),
lines: Vec::new(), lines: Vec::new(),
}); });
} }
let name = file require_object(file, &format!("files[{file_index}]"))?;
.get("path") let name = required_string(file, "path")?;
.or_else(|| file.get("name"))? let lines = match file.get("line_ranges") {
.as_str()? None => Vec::new(),
.to_string(); Some(value) => value
let lines = file .as_array()
.get("line_ranges") .ok_or_else(|| format!("invalid files[{file_index}].line_ranges: expected an array"))?
.and_then(serde_json::Value::as_array) .iter()
.into_iter() .enumerate()
.flatten() .map(|(range_index, range)| {
.filter_map(|range| { require_object(
let start = usize::try_from(range.get("start")?.as_u64()?).ok()?; range,
let end = usize::try_from(range.get("end")?.as_u64()?).ok()?; &format!("files[{file_index}].line_ranges[{range_index}]"),
(start > 0 && end >= start).then_some(start..end) )?;
}) let start = required_line_number(range, "start", file_index, range_index)?;
.collect(); let inclusive_end = required_line_number(range, "end", file_index, range_index)?;
Some(FileLocations { name, lines }) if inclusive_end < start {
return Err(format!(
"invalid files[{file_index}].line_ranges[{range_index}]: end must be greater than or equal to start"
));
}
let exclusive_end = inclusive_end.checked_add(1).ok_or_else(|| {
format!(
"invalid files[{file_index}].line_ranges[{range_index}].end: inclusive end is too large"
)
})?;
Ok(start..exclusive_end)
})
.collect::<Result<_, String>>()?,
};
Ok(FileLocations { name, lines })
} }
fn file_edits(input: &serde_json::Value) -> Vec<FileEdit> { fn required_line_number(
let diffs = input range: &serde_json::Value,
.get("diffs") key: &str,
.and_then(serde_json::Value::as_array) file_index: usize,
.into_iter() range_index: usize,
.flatten() ) -> Result<usize, String> {
.map(|diff| { let value = range
FileEdit::Edit(ParsedDiff::StrReplaceEdit { .get(key)
file: nonempty_string(diff, "file_path"), .ok_or_else(|| format!("missing required field {key:?}"))?
search: nonempty_string(diff, "search"), .as_u64()
replace: nonempty_string(diff, "replace"), .and_then(|value| usize::try_from(value).ok())
}) .filter(|value| *value > 0)
}); .ok_or_else(|| {
let creates = input format!(
.get("new_files") "invalid files[{file_index}].line_ranges[{range_index}].{key}: expected a positive integer"
.and_then(serde_json::Value::as_array) )
.into_iter() })?;
.flatten() Ok(value)
.map(|file| FileEdit::Create { }
file: nonempty_string(file, "file_path"),
content: nonempty_string(file, "content"), fn file_edits(input: &serde_json::Value) -> Result<Vec<FileEdit>, String> {
}); let mut edits = Vec::new();
let deletes = input if let Some(diffs) = optional_array(input, "diffs")? {
.get("deleted_files") for (index, diff) in diffs.iter().enumerate() {
.and_then(serde_json::Value::as_array) require_object(diff, &format!("diffs[{index}]"))?;
.into_iter() edits.push(FileEdit::Edit(ParsedDiff::StrReplaceEdit {
.flatten() file: Some(required_string(diff, "file_path")?),
.map(|file| FileEdit::Delete { search: Some(required_string(diff, "search")?),
file: file replace: Some(required_string(diff, "replace")?),
}));
}
}
if let Some(files) = optional_array(input, "new_files")? {
for (index, file) in files.iter().enumerate() {
require_object(file, &format!("new_files[{index}]"))?;
edits.push(FileEdit::Create {
file: Some(required_string(file, "file_path")?),
content: Some(required_string(file, "content")?),
});
}
}
if let Some(files) = optional_array(input, "deleted_files")? {
for (index, file) in files.iter().enumerate() {
let path = file
.as_str() .as_str()
.map(ToOwned::to_owned) .ok_or_else(|| format!("invalid deleted_files[{index}]: expected a string"))?;
.or_else(|| nonempty_string(file, "file_path")), edits.push(FileEdit::Delete {
}); file: Some(path.to_owned()),
diffs.chain(creates).chain(deletes).collect() });
}
}
if edits.is_empty() {
return Err(
"invalid file edits: expected at least one diff, new file, or deleted file".to_string(),
);
}
Ok(edits)
}
fn optional_array<'a>(
input: &'a serde_json::Value,
key: &str,
) -> Result<Option<&'a Vec<serde_json::Value>>, String> {
input
.get(key)
.map(|value| {
value
.as_array()
.ok_or_else(|| format!("invalid field {key:?}: expected an array"))
})
.transpose()
} }
#[cfg(test)] #[cfg(test)]
+241
View File
@@ -46,6 +46,26 @@ fn shell_calls_become_domain_actions_without_a_proto_round_trip() {
)); ));
} }
#[test]
fn transfer_control_calls_become_domain_actions() {
let action = action_from_tool_call(
"task-1",
&call(
"transfer_shell_command_control_to_user",
serde_json::json!({"reason": "The command needs interactive input"}),
),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
assert!(matches!(
action.action,
AIAgentActionType::TransferShellCommandControlToUser { reason }
if reason == "The command needs interactive input"
));
}
#[test] #[test]
fn create_plan_calls_become_document_actions() { fn create_plan_calls_become_document_actions() {
let action = action_from_tool_call( let action = action_from_tool_call(
@@ -72,6 +92,227 @@ fn create_plan_calls_become_document_actions() {
assert_eq!(request.documents[0].content, "# Implementation plan"); assert_eq!(request.documents[0].content, "# Implementation plan");
} }
#[test]
fn read_files_converts_advertised_inclusive_ranges_to_half_open_ranges() {
let action = action_from_tool_call(
"task-1",
&call(
"read_files",
serde_json::json!({
"files": [{
"path": "/tmp/example.rs",
"line_ranges": [
{"start": 1, "end": 1},
{"start": 10, "end": 25}
]
}]
}),
),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
let AIAgentActionType::ReadFiles(request) = action.action else {
panic!("expected read-files action");
};
assert_eq!(request.locations[0].lines, vec![1..2, 10..26]);
}
#[test]
fn known_tools_reject_malformed_required_inputs() {
let cases = [
("read_files", serde_json::json!({}), "files"),
(
"read_files",
serde_json::json!({"files": "not-an-array"}),
"expected an array",
),
(
"read_files",
serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 0, "end": 1}]}]}),
"positive integer",
),
(
"read_files",
serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 3, "end": 2}]}]}),
"greater than or equal",
),
(
"read_files",
serde_json::json!({"files": [{"path": "/tmp/a", "line_ranges": [{"start": 1, "end": u64::MAX}]}]}),
"inclusive end is too large",
),
(
"grep",
serde_json::json!({"queries": ["ok", 7]}),
"queries[1]",
),
(
"file_glob",
serde_json::json!({"patterns": false}),
"expected an array",
),
(
"search_codebase",
serde_json::json!({"query": 42}),
"expected a string",
),
(
"apply_file_diffs",
serde_json::json!({"summary": "edit", "diffs": [{"file_path": "/tmp/a", "search": "x"}]}),
"replace",
),
(
"apply_file_diffs",
serde_json::json!({"summary": "Nothing to do"}),
"at least one diff",
),
(
"run_shell_command",
serde_json::json!({"command": 42}),
"expected a string",
),
(
"run_shell_command",
serde_json::json!({"command": " "}),
"non-empty string",
),
(
"run_shell_command",
serde_json::json!({"command": "pwd", "is_read_only": "yes"}),
"expected a boolean",
),
(
"write_to_long_running_shell_command",
serde_json::json!({"command_id": "command-1", "input": "yes", "mode": "words"}),
"mode",
),
(
"interrupt_shell_command",
serde_json::json!({}),
"command_id",
),
(
"read_shell_command_output",
serde_json::json!({"command_id": 12}),
"expected a string",
),
(
"read_shell_command_output",
serde_json::json!({"command_id": "command-1", "wait_seconds": 11}),
"no greater than",
),
(
"run_agents",
serde_json::json!({"summary": "Investigate", "agent_run_configs": []}),
"at least one item",
),
(
"run_agents",
serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one"}]}),
"prompt",
),
(
"run_agents",
serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one", "prompt": "Inspect"}], "execution_mode": {"type": "other"}}),
"execution_mode.type",
),
(
"run_agents",
serde_json::json!({"summary": "Investigate", "agent_run_configs": [{"name": "one", "prompt": "Inspect"}], "skills": [{"skill": "test", "reference_type": "other"}]}),
"skills[0].reference_type",
),
(
"start_agent",
serde_json::json!({"name": "worker"}),
"prompt",
),
(
"transfer_shell_command_control_to_user",
serde_json::json!({"reason": false}),
"expected a string",
),
(
"wait_for_events",
serde_json::json!({"idle_timeout_seconds": -1}),
"non-negative",
),
(
"create_plan",
serde_json::json!({"documents": [{"title": "Plan"}]}),
"content",
),
(
"read_skill",
serde_json::json!({"skill": "/tmp/SKILL.md", "reference_type": "other"}),
"reference_type",
),
(
"fetch_conversation",
serde_json::json!({"conversation_id": null}),
"expected a string",
),
];
for (name, arguments, expected_error) in cases {
let error = action_from_tool_call(
"task-1",
&call(name, arguments),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap_err();
assert!(
error.contains(expected_error),
"{name} error {error:?} did not contain {expected_error:?}"
);
}
}
#[test]
fn known_tools_preserve_legitimate_optional_defaults() {
let cases = [
("grep", serde_json::json!({"queries": ["needle"]})),
("file_glob", serde_json::json!({"patterns": ["**/*.rs"]})),
(
"ask_user_question",
serde_json::json!({"question": "Continue?"}),
),
(
"apply_file_diffs",
serde_json::json!({"summary": "Create file", "new_files": [{"file_path": "/tmp/new", "content": ""}]}),
),
("run_shell_command", serde_json::json!({"command": "pwd"})),
(
"write_to_long_running_shell_command",
serde_json::json!({"command_id": "command-1", "input": ""}),
),
(
"read_shell_command_output",
serde_json::json!({"command_id": "command-1"}),
),
(
"run_agents",
serde_json::json!({
"summary": "Investigate",
"agent_run_configs": [{"name": "worker", "prompt": "Inspect"}]
}),
),
("wait_for_events", serde_json::json!({})),
];
for (name, arguments) in cases {
action_from_tool_call(
"task-1",
&call(name, arguments),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap_or_else(|error| panic!("{name} rejected optional defaults: {error}"));
}
}
#[test] #[test]
fn edit_calls_preserve_file_edits_in_the_domain_model() { fn edit_calls_preserve_file_edits_in_the_domain_model() {
let action = action_from_tool_call( let action = action_from_tool_call(
+46
View File
@@ -0,0 +1,46 @@
use std::backtrace::Backtrace;
use std::ffi::OsStr;
use std::sync::OnceLock;
const ENV_VAR: &str = "GALAXY_TOOL_DIAGNOSTICS";
fn env_value_is_enabled(value: Option<&OsStr>) -> bool {
value
.and_then(OsStr::to_str)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
}
pub(crate) fn is_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| env_value_is_enabled(std::env::var_os(ENV_VAR).as_deref()))
}
pub(crate) fn capture_backtrace() -> Option<Backtrace> {
is_enabled().then(Backtrace::force_capture)
}
macro_rules! tool_debug {
($($arg:tt)*) => {
if $crate::ai::tool_diagnostics::is_enabled() {
log::debug!("[tool-debug] {}", format_args!($($arg)*));
}
};
}
pub(crate) use tool_debug;
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use super::env_value_is_enabled;
#[test]
fn diagnostic_env_accepts_only_explicit_true_values() {
assert!(env_value_is_enabled(Some(OsStr::new("1"))));
assert!(env_value_is_enabled(Some(OsStr::new("TRUE"))));
assert!(!env_value_is_enabled(Some(OsStr::new("0"))));
assert!(!env_value_is_enabled(Some(OsStr::new("yes"))));
assert!(!env_value_is_enabled(None));
}
}
+43 -6
View File
@@ -7254,8 +7254,22 @@ impl TerminalView {
event: &BlocklistAIActionEvent, event: &BlocklistAIActionEvent,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
let event_matches_active_conversation = || {
let Some(event_conversation_id) = event.conversation_id() else {
return true;
};
self.model
.lock()
.block_list()
.active_block()
.ai_conversation_id()
== Some(event_conversation_id)
};
match event { match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
if !event_matches_active_conversation() {
return;
}
let is_agent_in_control = self let is_agent_in_control = self
.model .model
.lock() .lock()
@@ -7267,14 +7281,20 @@ impl TerminalView {
} }
} }
BlocklistAIActionEvent::ExecutingAction { .. } => { BlocklistAIActionEvent::ExecutingAction { .. } => {
self.redetermine_terminal_focus(ctx); if event_matches_active_conversation() {
self.redetermine_terminal_focus(ctx);
}
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::FinishedAction { action_id, .. } => { BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
..
} => {
// Refresh git line changes when files are potentially updated by an action // Refresh git line changes when files are potentially updated by an action
let action_result = action_model let action_result = action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(action_id) .get_action_result(*conversation_id, action_id)
.cloned(); .cloned();
let maybe_modified_files = action_result let maybe_modified_files = action_result
@@ -7644,7 +7664,11 @@ impl TerminalView {
drop(model); drop(model);
self.cli_subagent_controller.update(ctx, |controller, _| { self.cli_subagent_controller.update(ctx, |controller, _| {
controller.track_requested_command(&block_id, action_id); controller.track_requested_command(
&block_id,
parent_conversation_id,
action_id,
);
}); });
ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent { ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent {
@@ -7672,10 +7696,23 @@ impl TerminalView {
ShellCommandExecutorEvent::WriteToPty { input, mode } => { ShellCommandExecutorEvent::WriteToPty { input, mode } => {
self.write_agent_bytes_to_pty(input.to_vec(), mode, ctx); self.write_agent_bytes_to_pty(input.to_vec(), mode, ctx);
} }
ShellCommandExecutorEvent::CancelExecution => { ShellCommandExecutorEvent::CancelExecution { action_id } => {
// We need to manually invoke ctrl-c to terminate the running command because the // We need to manually invoke ctrl-c to terminate the running command because the
// user's ctrl-c was directed to the AIBlock instead of the command's shell block. // user's ctrl-c was directed to the AIBlock instead of the command's shell block.
self.ctrl_c(ctx); let is_exact_active_command = self
.model
.lock()
.block_list()
.active_block()
.requested_command_action_id()
.is_some_and(|requested_id| requested_id == action_id);
if is_exact_active_command {
self.ctrl_c(ctx);
} else {
log::warn!(
"Refusing to interrupt active terminal command for stale requested action {action_id}"
);
}
} }
ShellCommandExecutorEvent::TransferControlToUser { reason, .. } => { ShellCommandExecutorEvent::TransferControlToUser { reason, .. } => {
// Transfer control of the long-running command to the user. // Transfer control of the long-running command to the user.
+4 -2
View File
@@ -178,8 +178,10 @@ impl TerminalView {
let mut result = Vec::new(); let mut result = Vec::new();
for exchange in conversation.root_task_exchanges() { for exchange in conversation.root_task_exchanges() {
let formatted_exchange = let formatted_exchange = exchange.format_for_copy_for_conversation(
exchange.format_for_copy(Some(self.ai_action_model.as_ref(ctx))); Some(self.ai_action_model.as_ref(ctx)),
Some(conversation_id),
);
if !formatted_exchange.is_empty() { if !formatted_exchange.is_empty() {
result.push(formatted_exchange); result.push(formatted_exchange);
} }
+10 -4
View File
@@ -364,7 +364,9 @@ impl TerminalView {
}) => { }) => {
if let Some(result) = if let Some(result) =
self.ai_action_model.read(ctx, |action_model, _| { self.ai_action_model.read(ctx, |action_model, _| {
action_model.get_action_result(&action.id).cloned() action_model
.get_action_result(conversation_id, &action.id)
.cloned()
}) })
{ {
if let AIAgentActionResultType::CreateDocuments( if let AIAgentActionResultType::CreateDocuments(
@@ -402,7 +404,9 @@ impl TerminalView {
AIAgentActionType::EditDocuments { .. } => { AIAgentActionType::EditDocuments { .. } => {
if let Some(result) = if let Some(result) =
self.ai_action_model.read(ctx, |action_model, _| { self.ai_action_model.read(ctx, |action_model, _| {
action_model.get_action_result(&action.id).cloned() action_model
.get_action_result(conversation_id, &action.id)
.cloned()
}) })
{ {
if let AIAgentActionResultType::EditDocuments( if let AIAgentActionResultType::EditDocuments(
@@ -449,8 +453,10 @@ impl TerminalView {
for conversation in &conversations { for conversation in &conversations {
self.ai_action_model.update(ctx, |action_model, _ctx| { self.ai_action_model.update(ctx, |action_model, _ctx| {
action_model action_model.restore_action_results_from_exchanges(
.restore_action_results_from_exchanges(exchanges_for_blocklist(conversation)); conversation.id(),
exchanges_for_blocklist(conversation),
);
}); });
} }
@@ -94,6 +94,25 @@ impl TryFrom<RequestCommandOutputResult> for api::request::input::tool_call_resu
}, },
), ),
), ),
RequestCommandOutputResult::ExecutionError { command, message } => Ok(
api::request::input::tool_call_result::Result::RunShellCommand(
#[allow(deprecated)]
api::RunShellCommandResult {
command,
output: Default::default(),
exit_code: Default::default(),
result: Some(api::run_shell_command_result::Result::CommandFinished(
api::ShellCommandFinished {
command_id: String::new(),
output: format!("Command was not executed: {message}"),
exit_code: 1,
start_ts: None,
finish_ts: None,
},
)),
},
),
),
RequestCommandOutputResult::Denylisted { command } => RequestCommandOutputResult::Denylisted { command } =>
{ {
#[allow(deprecated)] #[allow(deprecated)]
@@ -1551,6 +1570,14 @@ impl From<RunAgentsAgentOutcome> for api::run_agents_result::AgentOutcome {
api::run_agents_result::LaunchedAgent { agent_id }, api::run_agents_result::LaunchedAgent { agent_id },
) )
} }
// The legacy wire schema has no completed-child shape. Preserve the child identity;
// direct-provider history retains the richer local result and output.
RunAgentsAgentOutcomeKind::Completed {
agent_id,
output: _,
} => api::run_agents_result::agent_outcome::Result::Launched(
api::run_agents_result::LaunchedAgent { agent_id },
),
RunAgentsAgentOutcomeKind::Failed { error } => { RunAgentsAgentOutcomeKind::Failed { error } => {
api::run_agents_result::agent_outcome::Result::Failed( api::run_agents_result::agent_outcome::Result::Failed(
api::run_agents_result::FailedAgent { error }, api::run_agents_result::FailedAgent { error },
@@ -28,3 +28,22 @@ fn ask_user_question_skipped_by_auto_approve_converts_to_skipped_answers() {
Some(AskUserQuestionAnswer::Skipped(())) Some(AskUserQuestionAnswer::Skipped(()))
)); ));
} }
#[test]
fn completed_run_agents_child_converts_to_legacy_launched_wire_outcome() {
let outcome = RunAgentsAgentOutcome {
name: "research".to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: "child-1".to_string(),
output: "local output".to_string(),
},
};
let converted = api::run_agents_result::AgentOutcome::from(outcome);
assert!(matches!(
converted.result,
Some(api::run_agents_result::agent_outcome::Result::Launched(
api::run_agents_result::LaunchedAgent { agent_id }
)) if agent_id == "child-1"
));
}
+54 -7
View File
@@ -165,6 +165,7 @@ impl AIAgentActionResultType {
None, None,
), ),
RequestCommandOutputResult::CancelledBeforeExecution RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. } => result.to_string(), | RequestCommandOutputResult::Denylisted { .. } => result.to_string(),
}, },
Self::WriteToLongRunningShellCommand(result) => match result { Self::WriteToLongRunningShellCommand(result) => match result {
@@ -418,6 +419,8 @@ pub enum RequestCommandOutputResult {
/// A running command canceled via ctrl-c /// A running command canceled via ctrl-c
/// would have Completed result with exit code 130. /// would have Completed result with exit code 130.
CancelledBeforeExecution, CancelledBeforeExecution,
/// The command could not start because the terminal was unavailable for execution.
ExecutionError { command: String, message: String },
/// The command was denied because it was present on the denylist. /// The command was denied because it was present on the denylist.
Denylisted { command: String }, Denylisted { command: String },
} }
@@ -427,14 +430,16 @@ impl RequestCommandOutputResult {
match self { match self {
Self::Completed { exit_code, .. } => exit_code.was_successful(), Self::Completed { exit_code, .. } => exit_code.was_successful(),
Self::LongRunningCommandSnapshot { .. } => true, Self::LongRunningCommandSnapshot { .. } => true,
Self::CancelledBeforeExecution | Self::Denylisted { .. } => false, Self::CancelledBeforeExecution
| Self::ExecutionError { .. }
| Self::Denylisted { .. } => false,
} }
} }
pub fn failed(&self) -> bool { pub fn failed(&self) -> bool {
match self { match self {
Self::Completed { exit_code, .. } => !exit_code.was_successful(), Self::Completed { exit_code, .. } => !exit_code.was_successful(),
Self::Denylisted { .. } => true, Self::ExecutionError { .. } | Self::Denylisted { .. } => true,
Self::CancelledBeforeExecution | Self::LongRunningCommandSnapshot { .. } => false, Self::CancelledBeforeExecution | Self::LongRunningCommandSnapshot { .. } => false,
} }
} }
@@ -444,6 +449,7 @@ impl RequestCommandOutputResult {
match self { match self {
Self::Completed { command, .. } Self::Completed { command, .. }
| Self::LongRunningCommandSnapshot { command, .. } | Self::LongRunningCommandSnapshot { command, .. }
| Self::ExecutionError { command, .. }
| Self::Denylisted { command } => command.clone(), | Self::Denylisted { command } => command.clone(),
Self::CancelledBeforeExecution => "cancelled".to_string(), Self::CancelledBeforeExecution => "cancelled".to_string(),
} }
@@ -473,6 +479,9 @@ impl Display for RequestCommandOutputResult {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
write!(f, "Command output cancelled") write!(f, "Command output cancelled")
} }
RequestCommandOutputResult::ExecutionError { command, message } => {
write!(f, "Command '{command}' could not be executed: {message}")
}
RequestCommandOutputResult::Denylisted { .. } => { RequestCommandOutputResult::Denylisted { .. } => {
write!(f, "Command output was on denylist") write!(f, "Command output was on denylist")
} }
@@ -1042,7 +1051,9 @@ impl AIAgentActionResultType {
| TransferShellCommandControlToUserResult::CommandFinished { .. }, | TransferShellCommandControlToUserResult::CommandFinished { .. },
) => true, ) => true,
Self::AskUserQuestion(AskUserQuestionResult::Success { .. }) => true, Self::AskUserQuestion(AskUserQuestionResult::Success { .. }) => true,
Self::RunAgents(RunAgentsResult::Launched { .. }) => true, Self::RunAgents(RunAgentsResult::Launched { agents, .. }) => agents
.iter()
.any(|agent| !matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })),
Self::WaitForEvents(WaitForEventsResult::Completed) => true, Self::WaitForEvents(WaitForEventsResult::Completed) => true,
_ => false, _ => false,
} }
@@ -1076,6 +1087,12 @@ impl AIAgentActionResultType {
| Self::RunAgents(RunAgentsResult::Failure { .. } | RunAgentsResult::Denied { .. }) => { | Self::RunAgents(RunAgentsResult::Failure { .. } | RunAgentsResult::Denied { .. }) => {
true true
} }
Self::RunAgents(RunAgentsResult::Launched { agents, .. }) => {
!agents.is_empty()
&& agents
.iter()
.all(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. }))
}
_ => false, _ => false,
} }
} }
@@ -1627,6 +1644,7 @@ pub struct RunAgentsAgentOutcome {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RunAgentsAgentOutcomeKind { pub enum RunAgentsAgentOutcomeKind {
Launched { agent_id: String }, Launched { agent_id: String },
Completed { agent_id: String, output: String },
Failed { error: String }, Failed { error: String },
} }
@@ -1654,6 +1672,17 @@ impl RunAgentsResult {
"computer_use_enabled": computer_use_enabled, "computer_use_enabled": computer_use_enabled,
}), }),
}; };
let children_completed = agents.iter().all(|agent| {
matches!(
agent.kind,
RunAgentsAgentOutcomeKind::Completed { .. }
| RunAgentsAgentOutcomeKind::Failed { .. }
)
});
let all_failed = !agents.is_empty()
&& agents.iter().all(|agent| {
matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })
});
let agents = agents let agents = agents
.iter() .iter()
.map(|agent| match &agent.kind { .map(|agent| match &agent.kind {
@@ -1662,6 +1691,14 @@ impl RunAgentsResult {
"status": "launched", "status": "launched",
"agent_id": agent_id, "agent_id": agent_id,
}), }),
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => {
serde_json::json!({
"name": agent.name,
"status": "completed",
"agent_id": agent_id,
"output": output,
})
}
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({ RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name, "name": agent.name,
"status": "failed", "status": "failed",
@@ -1670,9 +1707,13 @@ impl RunAgentsResult {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
serde_json::json!({ serde_json::json!({
"status": "launched", "status": if all_failed { "failure" } else { "launched" },
"completion_state": "children_running", "completion_state": if children_completed { "children_completed" } else { "children_running" },
"instruction": "Child agents have only been launched, not completed. Wait for child-agent updates before repeating this work or reporting final results.", "instruction": if children_completed {
"Child agents reached terminal states. Use their structured outputs and errors to complete the task."
} else {
"Child agents have only been launched, not completed. Wait for child-agent updates before repeating this work or reporting final results."
},
"model_id": model_id, "model_id": model_id,
"harness_type": harness_type, "harness_type": harness_type,
"execution_mode": execution_mode, "execution_mode": execution_mode,
@@ -1701,7 +1742,13 @@ impl Display for RunAgentsResult {
RunAgentsResult::Launched { agents, .. } => { RunAgentsResult::Launched { agents, .. } => {
let launched = agents let launched = agents
.iter() .iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. })) .filter(|a| {
matches!(
a.kind,
RunAgentsAgentOutcomeKind::Launched { .. }
| RunAgentsAgentOutcomeKind::Completed { .. }
)
})
.count(); .count();
write!( write!(
f, f,
+85 -2
View File
@@ -1,8 +1,22 @@
use super::{ use super::{
AIAgentActionResultType, RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, AIAgentActionResultType, RequestCommandOutputResult, RunAgentsAgentOutcome,
RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult, StartAgentVersion, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult,
StartAgentVersion,
}; };
#[test]
fn shell_execution_error_is_failed_but_not_cancelled() {
let result =
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
command: "cargo test".to_string(),
message: "terminal is busy".to_string(),
});
assert!(result.is_failed());
assert!(!result.is_cancelled());
assert!(result.model_content().contains("terminal is busy"));
}
#[test] #[test]
fn deserializes_legacy_start_agent_success_without_version_as_v1() { fn deserializes_legacy_start_agent_success_without_version_as_v1() {
let result: StartAgentResult = let result: StartAgentResult =
@@ -134,3 +148,72 @@ fn run_agents_model_content_serializes_terminal_non_launch_outcomes() {
assert_eq!(content, expected); assert_eq!(content, expected);
} }
} }
#[test]
fn completed_local_run_agents_preserves_outputs_and_terminal_state() {
let result = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
model_id: "model".to_string(),
harness_type: "codex".to_string(),
execution_mode: RunAgentsLaunchedExecutionMode::Local,
agents: vec![RunAgentsAgentOutcome {
name: "research".to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: "child-1".to_string(),
output: "Found the root cause".to_string(),
},
}],
});
let content: serde_json::Value = serde_json::from_str(&result.model_content()).unwrap();
assert_eq!(content["status"], "launched");
assert_eq!(content["completion_state"], "children_completed");
assert_eq!(content["agents"][0]["status"], "completed");
assert_eq!(content["agents"][0]["output"], "Found the root cause");
assert!(result.is_successful());
assert!(!result.is_failed());
}
#[test]
fn all_failed_run_agents_is_failure_but_mixed_batch_is_successful() {
let failed = |name: &str, error: &str| RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Failed {
error: error.to_string(),
},
};
let result = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
model_id: "model".to_string(),
harness_type: "codex".to_string(),
execution_mode: RunAgentsLaunchedExecutionMode::Local,
agents: vec![failed("one", "first error"), failed("two", "second error")],
});
let content: serde_json::Value = serde_json::from_str(&result.model_content()).unwrap();
assert_eq!(content["status"], "failure");
assert_eq!(content["agents"][0]["error"], "first error");
assert_eq!(content["agents"][1]["error"], "second error");
assert!(result.is_failed());
assert!(!result.is_successful());
let mixed = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
model_id: "model".to_string(),
harness_type: "codex".to_string(),
execution_mode: RunAgentsLaunchedExecutionMode::Local,
agents: vec![
failed("one", "first error"),
RunAgentsAgentOutcome {
name: "two".to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: "child-2".to_string(),
output: "useful output".to_string(),
},
},
],
});
let mixed_content: serde_json::Value = serde_json::from_str(&mixed.model_content()).unwrap();
assert_eq!(mixed_content["status"], "launched");
assert_eq!(mixed_content["agents"][0]["error"], "first error");
assert_eq!(mixed_content["agents"][1]["output"], "useful output");
assert!(mixed.is_successful());
assert!(!mixed.is_failed());
}
+5 -1
View File
@@ -820,7 +820,8 @@ impl ProviderRun {
call_id: call.call.id.clone(), call_id: call.call.id.clone(),
}) })
} }
PendingToolCallState::PermissionPending { .. } | PendingToolCallState::Executing => { PendingToolCallState::Executing => Ok(()),
PendingToolCallState::PermissionPending { .. } => {
Err(invalid_tool_transition(call, "tool start")) Err(invalid_tool_transition(call, "tool start"))
} }
} }
@@ -840,6 +841,9 @@ impl ProviderRun {
call.state = PendingToolCallState::Resolved { result }; call.state = PendingToolCallState::Resolved { result };
Ok(()) Ok(())
} }
PendingToolCallState::Resolved {
result: completed_result,
} if completed_result == &result => Ok(()),
PendingToolCallState::Resolved { .. } => { PendingToolCallState::Resolved { .. } => {
Err(ProviderRunProtocolError::DuplicateToolUpdate { Err(ProviderRunProtocolError::DuplicateToolUpdate {
call_id: call.call.id.clone(), call_id: call.call.id.clone(),
@@ -358,6 +358,89 @@ fn parallel_tool_results_commit_atomically_in_original_call_order() {
assert_eq!(ids, vec!["first", "second"]); assert_eq!(ids, vec!["first", "second"]);
} }
#[test]
fn duplicate_tool_start_is_idempotent() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
run.start_tool(&batch.work_id, "read").unwrap();
let started = run.clone();
run.start_tool(&batch.work_id, "read").unwrap();
assert_eq!(run, started);
}
#[test]
fn identical_tool_completion_is_idempotent() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
let result = successful_result("read", "contents");
run.complete_tool(&batch.work_id, result.clone()).unwrap();
let completed = run.clone();
run.complete_tool(&batch.work_id, result).unwrap();
assert_eq!(run, completed);
}
#[test]
fn conflicting_tool_completion_is_rejected_without_mutation() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
run.complete_tool(&batch.work_id, successful_result("read", "contents"))
.unwrap();
let completed = run.clone();
assert_eq!(
run.complete_tool(&batch.work_id, successful_result("read", "different"))
.unwrap_err(),
ProviderRunProtocolError::DuplicateToolUpdate {
call_id: "read".to_string(),
}
);
assert_eq!(run, completed);
}
#[test]
fn duplicate_tool_callbacks_do_not_prevent_eventual_batch_completion() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("first", "read_files"),
tool_call("second", "grep"),
],
&["read_files", "grep"],
),
);
let first_result = successful_result("first", "one");
run.start_tool(&batch.work_id, "first").unwrap();
run.start_tool(&batch.work_id, "first").unwrap();
run.complete_tool(&batch.work_id, first_result.clone())
.unwrap();
run.complete_tool(&batch.work_id, first_result).unwrap();
run.complete_tool(&batch.work_id, successful_result("second", "two"))
.unwrap();
let ProviderRunState::AwaitingTools { batch: completed } = run.state() else {
panic!("expected completed tool batch");
};
assert!(completed.is_complete());
run.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
}
#[test] #[test]
fn exact_batch_submission_rejects_missing_duplicate_and_unknown_results_without_mutation() { fn exact_batch_submission_rejects_missing_duplicate_and_unknown_results_without_mutation() {
let mut run = run(); let mut run = run();