Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy

This commit is contained in:
Ryan Ward
2026-08-18 11:30:18 -05:00
297 changed files with 47942 additions and 13733 deletions
File diff suppressed because it is too large Load Diff
+286 -62
View File
@@ -24,6 +24,7 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::collections::HashSet;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
@@ -73,6 +74,7 @@ use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentWaitPolicy,
};
pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor;
@@ -106,6 +108,27 @@ use crate::util::image::{
use crate::util::openable_file_type::is_binary_file;
use crate::BlocklistAIHistoryModel;
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
- Complete the assigned task directly and stay within its stated scope.
- Do not launch, delegate to, or create additional agents.
- Report blockers and completion to the lead through the available coordination channel."#;
pub(super) fn child_agent_delegation_denial_reason(
conversation_id: AIConversationId,
ctx: &AppContext,
) -> Option<String> {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_child_agent_conversation())
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
}
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
}
/// Types of actions that can be executed in parallel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ParallelExecutionPolicy {
@@ -209,12 +232,6 @@ pub enum NotExecutedReason {
WaitingOnSharer,
}
impl NotExecutedReason {
pub fn needs_confirmation(&self) -> bool {
matches!(self, Self::NeedsConfirmation)
}
}
/// Result type for `BlocklistAIActionExecutor::try_to_execute_action`.
#[derive(Debug)]
pub(super) enum TryExecuteResult {
@@ -229,9 +246,36 @@ pub(super) enum TryExecuteResult {
#[derive(Clone)]
struct AsyncExecutingAction {
action: AIAgentAction,
/// The conversation this action belongs to so cancellation and follow-up scheduling remain
/// scoped even when several conversations have async actions in flight.
conversation_id: AIConversationId,
}
type AsyncExecutingActionKey = (AIConversationId, AIAgentActionId);
#[derive(Default)]
struct AsyncExecutingActions(
std::collections::HashMap<AsyncExecutingActionKey, AsyncExecutingAction>,
);
impl AsyncExecutingActions {
fn insert(&mut self, conversation_id: AIConversationId, running: AsyncExecutingAction) {
self.0
.insert((conversation_id, running.action.id.clone()), running);
}
fn get(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AsyncExecutingAction> {
self.0.get(&(conversation_id, action_id.clone()))
}
fn remove(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<AsyncExecutingAction> {
self.0.remove(&(conversation_id, action_id.clone()))
}
}
impl AsyncExecutingAction {
@@ -270,10 +314,9 @@ pub struct BlocklistAIActionExecutor {
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID.
/// We track them per action rather than as a single slot so multiple actions from the same
/// parallel phase can complete independently.
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
/// The actions currently executing asynchronously, scoped by conversation and action ID.
async_executing_actions: AsyncExecutingActions,
restored_action_ids: HashSet<AsyncExecutingActionKey>,
/// Reference to the terminal model for checking session sharing state.
terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -334,8 +377,9 @@ impl BlocklistAIActionExecutor {
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
let run_agents_executor = ctx
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
let run_agents_executor = ctx.add_model(|ctx| {
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
let ask_user_question_executor =
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
@@ -360,6 +404,7 @@ impl BlocklistAIActionExecutor {
use_computer_executor,
request_computer_use_executor,
async_executing_actions: Default::default(),
restored_action_ids: Default::default(),
terminal_model,
read_skill_executor,
fetch_conversation_executor,
@@ -371,12 +416,46 @@ impl BlocklistAIActionExecutor {
}
}
pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> {
pub fn async_executing_action(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.async_executing_actions
.get(action_id)
.get(conversation_id, action_id)
.map(|running| &running.action)
}
pub fn mark_restored_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
self.restored_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
self.run_agents_executor.update(ctx, |executor, _| {
executor.mark_recovery_actions(conversation_id, action_ids);
});
}
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
self.async_executing_actions
.0
.iter()
.any(|((running_conversation_id, _), running)| {
*running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
}
/// Returns the action_id of any running WaitForEvents action for the
/// given conversation. There is at most one (wait_for_events is
/// documented as exclusive within a turn).
@@ -384,10 +463,9 @@ impl BlocklistAIActionExecutor {
&self,
conversation_id: AIConversationId,
) -> Option<AIAgentActionId> {
self.async_executing_actions
.iter()
.find_map(|(action_id, running)| {
if running.conversation_id == conversation_id
self.async_executing_actions.0.iter().find_map(
|((running_conversation_id, action_id), running)| {
if *running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::WaitForEvents { .. }
@@ -397,7 +475,8 @@ impl BlocklistAIActionExecutor {
} else {
None
}
})
},
)
}
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
@@ -602,8 +681,8 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool,
ctx: &mut ModelContext<Self>,
) -> TryExecuteResult {
log::info!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id,
std::mem::discriminant(&action.action),
is_user_initiated
@@ -611,7 +690,9 @@ impl BlocklistAIActionExecutor {
// We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() {
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode");
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: BLOCKED - shared session viewer mode"
);
return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action),
@@ -624,8 +705,8 @@ impl BlocklistAIActionExecutor {
};
let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute,
is_agent_autonomous
);
@@ -637,8 +718,8 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation {
log::info!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id
);
return TryExecuteResult::NotExecuted {
@@ -657,6 +738,7 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
@@ -672,11 +754,13 @@ impl BlocklistAIActionExecutor {
}
}
log::info!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id,
std::mem::discriminant(&action.action)
);
let action_key = (conversation_id, action.id.clone());
let is_restored = self.restored_action_ids.remove(&action_key);
let action_clone = action.clone();
let execution = match &action.action {
AIAgentActionType::RequestCommandOutput { .. }
@@ -828,8 +912,8 @@ impl BlocklistAIActionExecutor {
};
let action_id = action_clone.id.clone();
log::info!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution {
AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction",
@@ -840,8 +924,8 @@ impl BlocklistAIActionExecutor {
);
match execution {
AnyActionExecution::NotReady => {
log::info!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: NOT READY - action_id={:?}",
action_id
);
TryExecuteResult::NotExecuted {
@@ -851,7 +935,7 @@ impl BlocklistAIActionExecutor {
}
AnyActionExecution::InvalidAction => {
log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
"try_to_execute_action: invalid action, action_id={:?}",
action_id
);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
@@ -865,24 +949,32 @@ impl BlocklistAIActionExecutor {
on_complete,
} => {
self.async_executing_actions.insert(
action_id.clone(),
conversation_id,
AsyncExecutingAction {
action: action_clone,
conversation_id,
},
);
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
});
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
if !is_restored {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
}
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: spawning ASYNC execution for action_id={:?}",
action_id
);
ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else {
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id);
let Some(running) = me
.async_executing_actions
.remove(conversation_id, &action_id)
else {
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
return;
};
let result = on_complete(result, ctx);
log::info!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id,
std::mem::discriminant(&result)
);
@@ -892,16 +984,19 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result,
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: None,
});
});
TryExecuteResult::ExecutedAsync
}
AnyActionExecution::Sync(action_result) => {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
});
if !is_restored {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
}
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
id: action_id,
@@ -933,6 +1028,7 @@ impl BlocklistAIActionExecutor {
pub fn cancel_running_async_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
@@ -941,13 +1037,42 @@ impl BlocklistAIActionExecutor {
if self.is_shared_session_viewer() {
return;
}
if let Some(running) = self.async_executing_actions.remove(action_id) {
if self
.async_executing_actions
.get(conversation_id, action_id)
.is_some_and(|running| {
matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
})
{
let termination_requested = self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(action_id, ctx)
});
if termination_requested {
// Keep the action in flight until block completion proves the process stopped.
// Its normal async completion will report the actual terminal exit status.
return;
}
}
if let Some(running) = self
.async_executing_actions
.remove(conversation_id, action_id)
{
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
crate::ai::tool_diagnostics::tool_debug!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
);
if running.is_shell_command_action() {
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Running action cancellation backtrace:\n{backtrace}");
}
if running.is_shell_command_action()
&& !matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
{
self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
});
@@ -957,7 +1082,11 @@ impl BlocklistAIActionExecutor {
});
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx);
executor.cancel_execution(conversation_id, &running.action.id, ctx);
});
} else if matches!(running.action.action, AIAgentActionType::StartAgent { .. }) {
self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_execution(conversation_id, &running.action.id);
});
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action
@@ -975,7 +1104,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id,
result: running.action.action.cancelled_result(),
}),
conversation_id: running.conversation_id,
conversation_id,
cancellation_reason: reason,
});
}
@@ -989,18 +1118,23 @@ impl BlocklistAIActionExecutor {
) {
let action_ids = self
.async_executing_actions
.0
.iter()
.filter_map(|(action_id, running)| {
(running.conversation_id == conversation_id).then_some(action_id.clone())
.filter_map(|((running_conversation_id, action_id), _)| {
(*running_conversation_id == conversation_id).then_some(action_id.clone())
})
.collect::<Vec<_>>();
for action_id in action_ids {
self.cancel_running_async_action(&action_id, reason, ctx);
self.cancel_running_async_action(conversation_id, &action_id, reason, ctx);
}
}
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
if cfg!(feature = "bedrock_smoke_test") {
if self
.restored_action_ids
.contains(&(input.conversation_id, input.action.id.clone()))
|| cfg!(feature = "bedrock_smoke_test")
{
return true;
}
match input.action.action {
@@ -1109,9 +1243,10 @@ impl Entity for BlocklistAIActionExecutor {
}
pub enum BlocklistAIActionExecutorEvent {
/// Emitted when an action is execution starts.
/// Emitted when an action begins execution.
ExecutingAction {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
},
/// Emitted when an action has finished.
@@ -1442,6 +1577,95 @@ async fn read_file_as_binary(file_path: &std::path::Path) -> Result<Vec<u8>, Fil
async_fs::read(file_path).await.map_err(FileLoadError::from)
}
#[cfg(test)]
mod async_executing_action_tests {
use super::*;
use crate::ai::agent::task::TaskId;
fn action(id: &str, task_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_owned()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new(task_id.to_owned()),
requires_result: true,
tool_name: Some("init_project".to_owned()),
}
}
#[test]
fn duplicate_action_ids_can_execute_concurrently_in_different_conversations() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
assert_eq!(running.0.len(), 2);
assert_eq!(
running
.get(first_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("first-task".to_owned())
);
assert_eq!(
running
.get(second_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("second-task".to_owned())
);
}
#[test]
fn duplicate_action_completion_and_cancellation_remove_only_the_matching_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
let completed = running.remove(first_conversation, &duplicate_id).unwrap();
assert_eq!(
completed.action.task_id,
TaskId::new("first-task".to_owned())
);
assert!(running.get(second_conversation, &duplicate_id).is_some());
let cancelled = running.remove(second_conversation, &duplicate_id).unwrap();
assert_eq!(
cancelled.action.task_id,
TaskId::new("second-task".to_owned())
);
assert!(running.0.is_empty());
}
}
#[cfg(all(test, feature = "local_fs"))]
#[path = "execute_tests.rs"]
mod tests;
@@ -83,6 +83,9 @@ fn initialize_ask_user_question_test(
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))]
{
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
..
} = input.action
else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
log::error!("CallMCPToolExecutor::execute: action type mismatch");
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
log::info!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name,
server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
);
let serde_json::Value::Object(mut arguments) = input.clone() else {
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
log::error!("CallMCPToolExecutor: input is not an object");
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
let Some(reconnecting_peer) = templatable_peer else {
log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
"CallMCPToolExecutor: MCP server for tool '{}' not found",
name_owned
);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
crate::ai::tool_diagnostics::tool_debug!(
"CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned
);
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
tool_name: String,
ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType {
log::info!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
crate::ai::tool_diagnostics::tool_debug!(
"handle_call_tool_result: tool_name={}, is_ok={}",
tool_name,
res.is_ok()
);
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns,
path
);
@@ -237,8 +237,8 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"GrepExecutor::execute: queries={:?}, path={:?}",
queries,
path
);
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
crate::ai::tool_diagnostics::tool_debug!(
"ReadFilesExecutor::execute: {} files requested",
locations.len()
);
@@ -42,10 +42,34 @@ use crate::terminal::model::session::SessionType;
use crate::{safe_warn, BlocklistAIHistoryModel};
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
type AppliedDiffs = (Vec<FileDiff>, DiffSessionType);
#[derive(Default)]
struct PendingAppliedDiffs {
by_action: HashMap<AIAgentActionId, AppliedDiffs>,
}
impl PendingAppliedDiffs {
fn buffer(
&mut self,
action_id: AIAgentActionId,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
) {
self.by_action.insert(action_id, (diffs, diff_session_type));
}
fn take(&mut self, action_id: &AIAgentActionId) -> Option<AppliedDiffs> {
self.by_action.remove(action_id)
}
}
pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>,
apply_diff_model: ModelHandle<ApplyDiffModel>,
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
/// Successfully applied diffs that completed before their view was registered.
pending_applied_diffs: PendingAppliedDiffs,
/// Set of action IDs where diff application failed.
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
terminal_view_id: EntityId,
@@ -62,6 +86,7 @@ impl RequestFileEditsExecutor {
active_session,
apply_diff_model,
diff_views: HashMap::new(),
pending_applied_diffs: PendingAppliedDiffs::default(),
diff_application_failures: HashMap::new(),
terminal_view_id,
}
@@ -117,15 +142,18 @@ impl RequestFileEditsExecutor {
.is_allowed()
}
/// Registers a diff view to handle a RequestFileEdits action.
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in
/// order for the necessary state to be set to handle the action.
/// Registers a diff view to handle a RequestFileEdits action and applies any diffs that
/// finished preprocessing before the UI observed the action.
pub fn register_requested_edits(
&mut self,
action_id: &AIAgentActionId,
view: &ViewHandle<CodeDiffView>,
ctx: &mut ModelContext<Self>,
) {
self.diff_views.insert(action_id.clone(), view.clone());
if let Some((diffs, diff_session_type)) = self.pending_applied_diffs.take(action_id) {
Self::apply_diffs_to_view(view, diffs, diff_session_type, ctx);
}
}
pub(super) fn execute(
@@ -145,14 +173,14 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor::execute: action_id={:?}",
id
);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"RequestFileEditsExecutor: no diff view found for action_id={:?}",
id
);
return ActionExecution::NotReady;
@@ -322,23 +350,43 @@ impl RequestFileEditsExecutor {
tx: oneshot::Sender<()>,
ctx: &mut ModelContext<Self>,
) {
tx.send(()).ok();
match applied_diffs {
Ok(applied_diffs) if !applied_diffs.is_empty() => {
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let diffs = applied_diffs
.into_iter()
.map(|diff| {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
FileDiff::new(diff.original_content, path, diff.diff_type)
})
.collect();
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
let Some(diff_view) = self.diff_views.get(&id) else {
log::warn!(
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view"
);
return;
};
let applied_diffs = match applied_diffs {
Ok(diffs) if !diffs.is_empty() => diffs,
if let Some(diff_view) = self.diff_views.get(&id).cloned() {
Self::apply_diffs_to_view(&diff_view, diffs, diff_session_type, ctx);
} else {
self.pending_applied_diffs
.buffer(id, diffs, diff_session_type);
}
}
Ok(_) => {
// We didn't generate any diffs--consider this a failure.
log::warn!("No diffs generated");
self.diff_application_failures
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
return;
}
Err(err) => {
safe_warn!(
@@ -346,38 +394,18 @@ impl RequestFileEditsExecutor {
full: ("Failed to generate diffs {err:?}")
);
self.diff_application_failures.insert(id, err);
return;
}
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let mut diffs = Vec::with_capacity(applied_diffs.len());
for diff in applied_diffs {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
diffs.push(file_diff);
}
// Set the session type on the diff view so save/delete/create routes
// through the correct FileModel backend.
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
tx.send(()).ok();
}
fn apply_diffs_to_view(
diff_view: &ViewHandle<CodeDiffView>,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
ctx: &mut ModelContext<Self>,
) {
diff_view.update(ctx, |diff_view, ctx| {
diff_view.set_diff_session_type(diff_session_type);
diff_view.set_candidate_diffs(diffs, ctx);
@@ -2,8 +2,36 @@ use std::collections::HashMap;
use ai::agent::action_result::AnyFileContent;
use ai::agent::FileLocations;
use ai::diff_validation::DiffType;
use super::updated_file_contexts_from_editor_buffers;
use super::{
updated_file_contexts_from_editor_buffers, AIAgentActionId, DiffSessionType, FileDiff,
PendingAppliedDiffs,
};
#[test]
fn applied_diffs_survive_until_delayed_view_registration() {
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut pending = PendingAppliedDiffs::default();
pending.buffer(
action_id.clone(),
vec![FileDiff::new(
"before".to_string(),
"/workspace/src/main.rs".to_string(),
DiffType::update(vec![], None),
)],
DiffSessionType::Local,
);
let (diffs, session_type) = pending
.take(&action_id)
.expect("buffered diffs should remain available for registration");
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].base.content, "before");
assert_eq!(diffs[0].base.file_path, "/workspace/src/main.rs");
assert!(matches!(session_type, DiffSessionType::Local));
assert!(pending.take(&action_id).is_none());
}
#[test]
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
@@ -2,7 +2,7 @@
//!
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
//! and aggregates the outcomes into a single `RunAgentsResult`.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
@@ -12,15 +12,21 @@ use ai::agent::action_result::{
};
use ai::agent::orchestration_config::OrchestrationConfig;
use ai::skills::SkillReference;
use futures::future::BoxFuture;
use futures::future::{join_all, BoxFuture};
use futures::FutureExt;
use galaxy_core::execution_mode::AppExecutionMode;
use settings::Setting;
use warp_cli::agent::Harness;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::start_agent::{
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
StartAgentRequestId, StartAgentWaitPolicy,
};
use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
@@ -34,6 +40,8 @@ use crate::ai::document::plan_publication::{
prepare_plan_publications, wait_for_plan_publications,
};
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
/// Per-child spawn timeout. If a child agent doesn't report back within
/// this window (e.g. binary not found, server error), the slot is failed
@@ -60,7 +68,8 @@ struct ExistingLaunchedAgent {
}
pub struct RunAgentsExecutor {
pending: HashMap<AIAgentActionId, PendingRunAgents>,
pending: HashMap<(AIConversationId, AIAgentActionId), PendingRunAgents>,
recovery_action_ids: HashSet<(AIConversationId, AIAgentActionId)>,
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
@@ -69,12 +78,20 @@ pub struct RunAgentsExecutor {
/// Lifecycle events for in-flight dispatches.
pub enum RunAgentsExecutorEvent {
SpawningStarted {
conversation_id: AIConversationId,
action_id: AIAgentActionId,
snapshot: RunAgentsSpawningSnapshot,
},
SpawningFinished {
conversation_id: AIConversationId,
action_id: AIAgentActionId,
},
ChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
}
impl Entity for RunAgentsExecutor {
@@ -85,31 +102,77 @@ impl RunAgentsExecutor {
pub fn new(
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&start_agent_executor, |_, _, event, ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
ctx.emit(RunAgentsExecutorEvent::ChildConversationCreated {
action_id: action_id.clone(),
agent_name: agent_name.clone(),
parent_conversation_id: *parent_conversation_id,
child_conversation_id: *child_conversation_id,
});
}
});
Self {
pending: HashMap::new(),
recovery_action_ids: HashSet::new(),
launched_agents: HashMap::new(),
start_agent_executor,
terminal_view_id,
}
}
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
self.pending.contains_key(action_id)
pub fn is_pending(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> bool {
self.pending
.contains_key(&(conversation_id, action_id.clone()))
}
/// Cancels a pending run so publication completion cannot fan out children.
pub fn mark_recovery_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
) {
self.recovery_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
}
pub(crate) fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}
/// Cancels the parent tool wait without cancelling independently-running children.
pub(super) fn cancel_execution(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) {
if matches!(
self.pending.get(action_id),
Some(PendingRunAgents::Publishing)
) {
self.pending.remove(action_id);
let action_key = (conversation_id, action_id.clone());
self.recovery_action_ids.remove(&action_key);
let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_dispatches_for_action(conversation_id, action_id)
});
crate::ai::tool_diagnostics::tool_debug!(
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
);
if self.pending.remove(&action_key).is_some() {
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id,
action_id: action_id.clone(),
});
}
@@ -122,6 +185,22 @@ impl RunAgentsExecutor {
) {
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
self.launched_agents
.entry(conversation_id)
.or_default()
.insert(
normalized_name,
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
},
);
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -164,14 +243,39 @@ impl RunAgentsExecutor {
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.pending.contains_key(&action_id) {
let action_key = (parent_conversation_id, action_id.clone());
if self.pending.contains_key(&action_key) {
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents dispatch rejected",
serde_json::json!({
"event": "run_agents_dispatch_rejected",
"reason": "reentered_pending_action",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
}),
);
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
log::warn!("RunAgentsExecutor: validation failure: {error}");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents validation failed",
serde_json::json!({
"event": "run_agents_validation_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"error": remote_logging::sanitize_error(&error),
}),
);
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
@@ -181,8 +285,22 @@ impl RunAgentsExecutor {
agent_count: request.agent_run_configs.len(),
};
self.pending
.insert(action_id.clone(), PendingRunAgents::Publishing);
.insert(action_key, PendingRunAgents::Publishing);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents plan publication wait started",
serde_json::json!({
"event": "run_agents_plan_publication_wait_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": snapshot.agent_count,
"plan_id_present": !request.plan_id.trim().is_empty(),
}),
);
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(),
snapshot,
});
@@ -197,13 +315,14 @@ impl RunAgentsExecutor {
request
},
move |me, request, ctx| {
if !me.is_pending(&action_id_for_wait) {
if !me.is_pending(parent_conversation_id, &action_id_for_wait) {
return;
}
me.dispatch_children_for_prepared_request(
action_id_for_wait.clone(),
request,
parent_conversation_id,
HashMap::new(),
sender,
ctx,
)
@@ -213,16 +332,56 @@ impl RunAgentsExecutor {
receiver
}
fn dispatch_recovered_run_agents(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
recovery_children: HashMap<String, AIConversationId>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.is_pending(parent_conversation_id, &action_id) {
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
let snapshot = RunAgentsSpawningSnapshot {
agent_count: request.agent_run_configs.len(),
};
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(),
snapshot,
});
self.dispatch_children_for_prepared_request(
action_id,
request,
parent_conversation_id,
recovery_children,
sender,
ctx,
);
receiver
}
fn dispatch_children_for_prepared_request(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
mut recovery_children: HashMap<String, AIConversationId>,
sender: async_channel::Sender<RunAgentsResult>,
ctx: &mut ModelContext<Self>,
) {
self.pending
.insert(action_id.clone(), PendingRunAgents::Spawning);
self.pending.insert(
(parent_conversation_id, action_id.clone()),
PendingRunAgents::Spawning,
);
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|c| c.run_id());
@@ -238,8 +397,46 @@ impl RunAgentsExecutor {
..
} = request;
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch started",
serde_json::json!({
"event": "run_agents_child_dispatch_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": agent_run_configs.len(),
"execution_mode": run_agents_execution_mode_label(&run_execution_mode),
"harness_type": harness_type.as_str(),
"model_id_present": !model_id.trim().is_empty(),
"parent_run_id_present": parent_run_id.is_some(),
}),
);
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
let wait_policy = match &run_execution_mode {
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
};
for cfg in &agent_run_configs {
let normalized_name = normalize_agent_name(&cfg.name)
.expect("validated RunAgents requests have non-empty agent names");
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.reattach(
action_id.clone(),
cfg.name.clone(),
parent_conversation_id,
child_conversation_id,
wait_policy,
exec_ctx,
)
});
slots.push(ChildSlot::Pending(dispatch));
continue;
}
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
let mode = match run_agents_to_start_agent_mode(
&run_execution_mode,
@@ -251,6 +448,19 @@ impl RunAgentsExecutor {
) {
Ok(mode) => mode,
Err(err) => {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents child dispatch failed before launch",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": remote_logging::sanitize_error(&err),
}),
);
slots.push(ChildSlot::Failed(err));
continue;
}
@@ -258,13 +468,40 @@ impl RunAgentsExecutor {
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
&& parent_run_id.is_none()
{
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents remote child dispatch missing parent run_id",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": "Remote child agents require the parent run_id to be available.",
}),
);
slots.push(ChildSlot::Failed(
"Remote child agents require the parent run_id to be available.".to_string(),
));
continue;
}
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch queued",
serde_json::json!({
"event": "run_agents_child_dispatch_queued",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"execution_mode": start_agent_execution_mode_label(&mode),
}),
);
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.dispatch(
action_id.clone(),
cfg.name.clone(),
prompt,
mode,
@@ -274,7 +511,7 @@ impl RunAgentsExecutor {
exec_ctx,
)
});
slots.push(ChildSlot::Pending(recv));
slots.push(ChildSlot::Pending(dispatch));
}
let agent_run_configs_for_result = agent_run_configs.clone();
@@ -283,65 +520,95 @@ impl RunAgentsExecutor {
let run_harness_type = harness_type.clone();
let run_execution_mode_for_aggr = run_execution_mode.clone();
let parent_conversation_id_for_result = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let action_id_for_async_log = action_id.clone();
#[cfg(not(target_family = "wasm"))]
let parent_conversation_id_for_async_log = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let agent_names_for_async_log = agent_run_configs
.iter()
.map(|cfg| cfg.name.clone())
.collect::<Vec<_>>();
ctx.spawn(
async move {
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
for slot in slots {
let kind = match slot {
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error },
ChildSlot::Pending(recv) => {
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT);
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((
Ok(StartAgentOutcome::Started { agent_id }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Completed { agent_id, .. }),
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Error(error)),
_,
)) => RunAgentsAgentOutcomeKind::Failed { error },
futures::future::Either::Left((Err(_), _)) => {
RunAgentsAgentOutcomeKind::Failed {
error: "Cancelled before launch".to_string(),
}
}
futures::future::Either::Right((_, _)) => {
log::warn!(
"Agent spawn timed out after {} seconds",
SPAWN_TIMEOUT.as_secs()
);
RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
SPAWN_TIMEOUT.as_secs()
),
}
}
}
}
};
outcomes.push(kind);
let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
#[cfg(not(target_family = "wasm"))]
for (slot_index, resolved) in resolved_slots.iter().enumerate() {
log::info!(
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
agent_name={} slot_index={} outcome={}",
action_id_for_async_log,
parent_conversation_id_for_async_log,
agent_names_for_async_log
.get(slot_index)
.map(String::as_str)
.unwrap_or("<unknown>"),
slot_index,
run_agents_agent_outcome_kind_label(&resolved.outcome)
);
}
outcomes
resolved_slots
},
move |me, outcomes, ctx| {
move |me, resolved_slots, ctx| {
if !me.is_pending(parent_conversation_id_for_result, &action_id_for_aggr) {
return;
}
let timed_out_request_ids = resolved_slots
.iter()
.filter_map(|resolved| resolved.timed_out_request_id)
.collect::<Vec<_>>();
if !timed_out_request_ids.is_empty() {
me.start_agent_executor.update(ctx, |executor, _| {
for request_id in timed_out_request_ids {
executor.detach_dispatch(request_id);
}
});
}
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter()
.zip(outcomes)
.map(|(cfg, kind)| RunAgentsAgentOutcome {
.zip(resolved_slots)
.map(|(cfg, resolved)| RunAgentsAgentOutcome {
name: cfg.name.clone(),
kind,
kind: resolved.outcome,
})
.collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents launch outcomes resolved",
serde_json::json!({
"event": "run_agents_launch_outcomes_resolved",
"action_id": action_id_for_aggr.to_string(),
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
"agent_count": agents.len(),
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
"agents": agents
.iter()
.map(|agent| match &agent.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id } => serde_json::json!({
"name": agent.name.as_str(),
"status": "launched",
"agent_id": agent_id.as_str(),
}),
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
"name": agent.name.as_str(),
"status": "completed",
"agent_id": agent_id.as_str(),
"output": output,
}),
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name.as_str(),
"status": "failed",
"error": remote_logging::sanitize_error(error),
}),
})
.collect::<Vec<_>>(),
}),
);
let launched_mode = match &run_execution_mode_for_aggr {
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
RunAgentsExecutionMode::Remote {
@@ -360,8 +627,12 @@ impl RunAgentsExecutor {
execution_mode: launched_mode,
agents,
};
me.pending.remove(&action_id_for_aggr);
me.pending.remove(&(
parent_conversation_id_for_result,
action_id_for_aggr.clone(),
));
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id: parent_conversation_id_for_result,
action_id: action_id_for_aggr,
});
let _ = sender.try_send(result);
@@ -381,20 +652,58 @@ impl RunAgentsExecutor {
let mut request = request.clone();
let action_id = id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
let is_recovery = self
.recovery_action_ids
.remove(&(parent_conversation_id, action_id.clone()));
let receiver =
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
let recovery_children = if is_recovery {
prepare_recovery_request_for_execution(&mut request, parent_conversation_id, ctx);
match recovery_children_by_name(parent_conversation_id, ctx) {
Ok(children) => children,
Err(error) => {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Failure { error },
));
}
}
} else {
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents execution denied",
serde_json::json!({
"event": "run_agents_execution_denied",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"reason": remote_logging::sanitize_error(&reason),
}),
);
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
HashMap::new()
};
let receiver = if is_recovery {
self.dispatch_recovered_run_agents(
action_id,
request,
parent_conversation_id,
recovery_children,
ctx,
)
} else {
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx)
};
ActionExecution::new_async(
async move { receiver.recv().await },
@@ -413,6 +722,9 @@ impl RunAgentsExecutor {
let AIAgentActionType::RunAgents(request) = &input.action.action else {
return false;
};
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
return true;
}
if AppExecutionMode::as_ref(ctx).is_autonomous() {
return true;
}
@@ -444,9 +756,123 @@ impl RunAgentsExecutor {
#[path = "run_agents_tests.rs"]
mod tests;
#[cfg(not(target_family = "wasm"))]
fn log_run_agents_event(
ctx: &mut ModelContext<RunAgentsExecutor>,
level: RemoteLogLevel,
message: impl Into<String>,
context: serde_json::Value,
) {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level,
message: message.into(),
context,
},
);
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_execution_mode_label(mode: &RunAgentsExecutionMode) -> &'static str {
match mode {
RunAgentsExecutionMode::Local => "local",
RunAgentsExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static str {
match mode {
StartAgentExecutionMode::Local { .. } => "local",
StartAgentExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
match kind {
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
}
}
enum ChildSlot {
Failed(String),
Pending(async_channel::Receiver<StartAgentOutcome>),
Pending(StartAgentDispatch),
}
#[derive(Debug)]
struct ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind,
timed_out_request_id: Option<StartAgentRequestId>,
}
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
}
async fn resolve_child_slot_with_timeout(
slot: ChildSlot,
spawn_timeout: Duration,
) -> ResolvedChildSlot {
let dispatch = match slot {
ChildSlot::Failed(error) => {
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed { error },
timed_out_request_id: None,
};
}
ChildSlot::Pending(dispatch) => dispatch,
};
let request_id = dispatch.request_id;
let outcome = match dispatch.wait_policy {
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
StartAgentWaitPolicy::Startup => {
let timeout = warpui::r#async::Timer::after(spawn_timeout);
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((outcome, _)) => outcome.ok(),
futures::future::Either::Right((_, _)) => {
dispatch.mark_detached();
log::warn!(
"Agent spawn timed out after {} seconds",
spawn_timeout.as_secs()
);
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
spawn_timeout.as_secs()
),
},
timed_out_request_id: Some(request_id),
};
}
}
}
};
let outcome = match outcome {
Some(StartAgentOutcome::Started { agent_id }) => {
RunAgentsAgentOutcomeKind::Launched { agent_id }
}
Some(StartAgentOutcome::Completed { agent_id, output }) => {
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
}
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
None => RunAgentsAgentOutcomeKind::Failed {
error: "Child agent was cancelled before completion".to_string(),
},
};
ResolvedChildSlot {
outcome,
timed_out_request_id: None,
}
}
fn approved_orchestration_config_can_autoexecute(
@@ -476,9 +902,9 @@ fn resolve_request_from_approved_config(
/// Normalizes the request and returns a denial reason when launch is blocked.
///
/// Autonomous agents always run: their calls may still inherit approved plan
/// config fields and default auth secrets, but they bypass interactive policy
/// denials because they cannot present a confirmation card.
/// Root autonomous agents bypass interactive policy denials because they cannot
/// present a confirmation card. Child-agent delegation is rejected before that
/// bypass, while allowed root calls still inherit approved config and auth fields.
fn prepare_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
@@ -486,6 +912,11 @@ fn prepare_request_for_execution(
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return Some(reason);
}
normalize_request_for_local_execution(request);
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) =
@@ -521,6 +952,42 @@ fn prepare_request_for_execution(
None
}
fn prepare_recovery_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) {
normalize_request_for_local_execution(request);
resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
}
fn recovery_children_by_name(
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Result<HashMap<String, AIConversationId>, String> {
let mut children_by_name = HashMap::new();
for conversation in
BlocklistAIHistoryModel::as_ref(ctx).child_conversations_of(parent_conversation_id)
{
let Some(name) = conversation.agent_name() else {
continue;
};
let Some(normalized_name) = normalize_agent_name(name) else {
continue;
};
if children_by_name
.insert(normalized_name.clone(), conversation.id())
.is_some()
{
return Err(format!(
"Cannot recover child agent '{name}': multiple persisted child conversations have the same name."
));
}
}
Ok(children_by_name)
}
fn duplicate_launched_agents_reason(
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
@@ -544,8 +1011,11 @@ fn duplicate_launched_agents_reason(
let duplicates = requested_agents
.iter()
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Option<Vec<_>>>()?;
.filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Vec<_>>();
if duplicates.is_empty() {
return None;
}
let duplicate_list = duplicates
.iter()
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
@@ -590,6 +1060,19 @@ fn existing_launched_agents_for_conversation(
};
for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
existing_agents.entry(normalized_name).or_insert_with(|| {
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
}
});
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -673,6 +1156,18 @@ fn populate_default_auth_secret_for_execution(
default_auth_secret_name_for_harness(&request.harness_type, ctx);
}
fn normalize_request_for_local_execution(request: &mut RunAgentsRequest) {
let edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
request.model_id = edit_state.model_id;
request.harness_type = edit_state.harness_type;
request.execution_mode = RunAgentsExecutionMode::Local;
request.harness_auth_secret_name = None;
}
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`.
@@ -696,6 +1191,23 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string());
}
if request.execution_mode.is_remote() {
return Err("Galaxy only supports local child-agent orchestration.".to_string());
}
let mut normalized_names = HashSet::new();
for config in &request.agent_run_configs {
let Some(normalized_name) = normalize_agent_name(&config.name) else {
return Err("orchestrate: agent names must not be empty".to_string());
};
if !normalized_names.insert(normalized_name) {
return Err(format!(
"orchestrate: duplicate agent name '{}' in the same batch",
config.name.trim()
));
}
}
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
if let Some(message) = local_harness_product_disabled_message(harness) {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{
@@ -91,6 +93,15 @@ fn persist_plan_config_with_harness(
});
}
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
});
}
#[test]
fn should_autoexecute_duplicate_launched_agent_denial() {
App::test((), |mut app| async move {
@@ -162,6 +173,522 @@ fn execute_denies_duplicate_launched_agent() {
});
}
#[test]
fn execute_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(
should_autoexecute,
"the denial should not require user approval"
);
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn execute_denies_mixed_batch_containing_launched_agent() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "new-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("child (agent-123)")
));
});
}
#[test]
fn validate_request_rejects_blank_and_duplicate_agent_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs[0].name = " ".to_string();
assert_eq!(
validate_request(&request),
Err("orchestrate: agent names must not be empty".to_string())
);
request.agent_run_configs[0].name = "Child".to_string();
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: " child ".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(
validate_request(&request),
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
);
}
#[test]
fn validate_request_allows_unique_sibling_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "second-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn local_normalization_clears_remote_only_fields_and_disabled_harness() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("codex").action else {
panic!("expected run_agents action");
};
request.model_id = "gpt-5".to_string();
request.harness_auth_secret_name = Some("remote-secret".to_string());
normalize_request_for_local_execution(&mut request);
assert!(matches!(
request.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(request.harness_type, "oz");
assert_eq!(request.model_id, "");
assert_eq!(request.harness_auth_secret_name, None);
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn validate_request_rejects_remote_dispatch() {
let AIAgentActionType::RunAgents(request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
assert_eq!(
validate_request(&request),
Err("Galaxy only supports local child-agent orchestration.".to_string())
);
}
#[test]
fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_child() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let existing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "missing-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
let missing_request = captured.read(&app, |captured, _| {
assert_eq!(captured.0.len(), 1);
assert_eq!(captured.0[0].name, "missing-child");
captured.0[0].clone()
});
history.update(&mut app, |history, ctx| {
history.update_conversation_status(
terminal_view_id,
existing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let missing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"missing-child".to_string(),
state.conversation_id,
None,
ctx,
)
});
history.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
missing_request.id,
missing_child_id,
ctx,
);
history.update_conversation_status(
terminal_view_id,
missing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched { agents, .. }) = result
else {
panic!("expected recovered RunAgents result");
};
assert_eq!(agents.len(), 2);
assert!(matches!(
&agents[0].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &existing_child_id.to_string()
));
assert!(matches!(
&agents[1].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &missing_child_id.to_string()
));
});
}
#[test]
fn cancelling_recovered_run_agents_keeps_persisted_child_running() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let action = remote_run_agents_action("oz");
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
state.executor.update(&mut app, |executor, ctx| {
executor.cancel_execution(state.conversation_id, &action.id, ctx);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled)
));
history.read(&app, |history, _| {
assert!(matches!(
history.conversation(&child_id).map(|child| child.status()),
Some(crate::ai::agent::conversation::ConversationStatus::InProgress)
));
});
});
}
#[test]
fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
App::test((), |_app| async move {
let (first_sender, first_receiver) = async_channel::bounded(1);
let (second_sender, second_receiver) = async_channel::bounded(1);
let slots = vec![
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver: first_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(2),
receiver: second_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Failed("prelaunch failure".to_string()),
];
let mut outcomes =
Box::pin(join_all(slots.into_iter().map(|slot| {
resolve_child_slot_with_timeout(slot, Duration::from_millis(1))
})));
second_sender
.try_send(StartAgentOutcome::Completed {
agent_id: "second-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(futures::poll!(&mut outcomes).is_pending());
assert!(
!second_sender.is_full(),
"join_all should poll and drain the second slot while the first is pending"
);
first_sender
.try_send(StartAgentOutcome::Error("first failed".to_string()))
.unwrap();
let outcomes = outcomes.await;
assert!(matches!(
&outcomes[0].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
));
assert!(matches!(
&outcomes[1].outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "second-agent" && output == "done"
));
assert!(matches!(
&outcomes[2].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
));
});
}
#[test]
fn completion_wait_ignores_spawn_timeout() {
App::test((), |_app| async move {
let (sender, receiver) = async_channel::bounded(1);
let completion = Box::pin(resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
));
let wait = warpui::r#async::Timer::after(Duration::from_millis(20));
let completion = match futures::future::select(completion, Box::pin(wait)).await {
futures::future::Either::Left((outcome, _)) => {
panic!("completion wait unexpectedly resolved before child completion: {outcome:?}")
}
futures::future::Either::Right((_, completion)) => completion,
};
sender
.try_send(StartAgentOutcome::Completed {
agent_id: "child-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(matches!(
completion.await.outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "child-agent" && output == "done"
));
});
}
#[test]
fn startup_wait_retains_spawn_timeout() {
App::test((), |_app| async move {
let (_sender, receiver) = async_channel::bounded(1);
let outcome = resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Startup,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
)
.await;
assert!(outcome.timed_out_request_id.is_some());
assert_eq!(
outcome.timed_out_request_id,
Some(StartAgentRequestId::from_raw_for_test(1))
);
assert!(matches!(
outcome.outcome,
RunAgentsAgentOutcomeKind::Failed { error }
if error.contains("Agent failed to start within")
));
});
}
#[test]
fn startup_timeout_detaches_exact_pending_request() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let start_agent_executor = state.start_agent_executor;
let parent_conversation_id = state.conversation_id;
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-timeout".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some("parent-run".to_string()),
ctx,
)
});
let request_id = dispatch.request_id;
let resolved =
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
.await;
let timed_out_request_id = resolved
.timed_out_request_id
.expect("startup timeout should expose request identity");
start_agent_executor.update(&mut app, |executor, _| {
assert!(executor.detach_dispatch(timed_out_request_id));
});
start_agent_executor.read(&app, |executor, _| {
assert!(!executor.has_pending_dispatch_for_test(request_id));
});
});
}
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -178,6 +705,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
@@ -190,8 +720,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let start_agent_executor = app.add_model(StartAgentExecutor::new);
let executor =
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
let executor = app.add_model(|ctx| {
RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
RunAgentsTestState {
conversation_id,
@@ -321,7 +852,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() {
}
#[test]
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
fn approved_remote_plan_is_normalized_and_can_autoexecute_locally() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config_with_harness(
@@ -343,7 +874,7 @@ fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_sec
)
});
assert!(!should_autoexecute);
assert!(should_autoexecute);
});
}
@@ -561,9 +1092,9 @@ fn cancel_during_plan_publication_does_not_dispatch_children() {
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
assert!(matches!(execution, AnyActionExecution::Async { .. }));
state.executor.update(&mut app, |executor, ctx| {
assert!(executor.is_pending(&action_id));
executor.cancel_execution(&action_id, ctx);
assert!(!executor.is_pending(&action_id));
assert!(executor.is_pending(state.conversation_id, &action_id));
executor.cancel_execution(state.conversation_id, &action_id, ctx);
assert!(!executor.is_pending(state.conversation_id, &action_id));
});
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
@@ -617,7 +1148,7 @@ fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
}
#[test]
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
fn execute_normalizes_remote_non_oz_harness_without_requiring_remote_auth() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("codex");
@@ -634,21 +1165,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
.into()
});
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(
reason,
"Cloud child agents using this harness require an API key before they can run."
);
assert!(matches!(execution, AnyActionExecution::Async { .. }));
});
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() {
fn normalized_remote_non_oz_harness_autoexecutes_with_always_allow() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -669,7 +1191,7 @@ fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_def
}
#[test]
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
fn normalized_remote_non_oz_harness_ignores_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -691,7 +1213,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
}
#[test]
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() {
fn normalized_remote_oz_harness_autoexecutes_without_default_auth_secret() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_util::path::ShellFamily;
use galaxyui::r#async::{Spawnable, Timer};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
event: &ModelEvent,
_ctx: &mut ModelContext<Self>,
) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
// evidence for shells that never deliver a subsequent precmd.
if matches!(
event,
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
| ModelEvent::BlockCompleted(_)
) {
let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
if let Some(block) = block_selector.get_block(&model) {
if block.is_command_finished() {
let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
for (block_selector, block_finished_txs) in block_finished_senders {
let completed_block = block_selector.get_block(&model).filter(|block| {
block.is_command_finished()
&& match event {
ModelEvent::BlockCompleted(completed) => {
block.id() == &completed.block_id
}
ModelEvent::BlockMetadataReceived(_) => true,
_ => false,
}
});
if completed_block.is_some() {
for block_finished_tx in block_finished_txs {
if let Err(e) = block_finished_tx.send(()) {
log::warn!(
"Failed to notify block completion for running requested command: {e:?}"
)
}
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
}
} else {
// The requested-command association may not exist yet. Keep all waiters until
// this selector resolves and its block actually completes, or it is cancelled.
self.block_finished_senders
.insert(block_selector, block_finished_txs);
}
}
}
@@ -190,30 +204,13 @@ impl ShellCommandExecutor {
}
}
/// Decorate the command so that we can turn off pager.
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
match self.active_session.as_ref(ctx).shell_type(ctx) {
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
// avoid cases with aliases.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
// gets evaluated first.
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
// For powershell, we use Out-Host to send paged output to the
// console. Add a backslash to avoid executing an alias.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
// If we can't determine a shell type, run command as it is.
None => command.clone(),
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
log::info!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
crate::ai::tool_diagnostics::tool_debug!(
"ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action)
);
let model = self.terminal_model.lock();
@@ -221,17 +218,10 @@ impl ShellCommandExecutor {
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle();
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
uses_pager,
wait_until_completion,
..
} => {
@@ -240,18 +230,13 @@ impl ShellCommandExecutor {
.active_block()
.is_active_and_long_running()
{
// Another command is still running (e.g. stuck in a pager). Return an error
// result so the model receives feedback and can adapt. Using Completed with a
// non-zero exit code ensures a follow-up request is triggered.
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed {
command: command.clone(),
block_id: model.block_list().active_block().id().clone(),
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
exit_code: ExitCode::from(1),
start_ts: None,
completed_ts: None,
},
let running_command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false);
return ActionExecution::Sync(terminal_busy_execution_error(
command,
&running_command,
));
}
// If another conversation has taken over the agent view since this command
@@ -266,15 +251,13 @@ impl ShellCommandExecutor {
RequestCommandOutputResult::CancelledBeforeExecution,
));
}
// If the command might use pager and can't be interacted with,
// we pipe its output to cat so we can prevent activating the altscreen.
// The parentheses here ensures the command always gets evaluated first.
let decorated_command =
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion {
self.turn_off_pager_for_command(command, ctx)
} else {
command.clone()
};
// A command expected to finish must not enter an implicit pager. Do not trust the
// model-provided pager hint: commands such as `git log` can page implicitly.
let decorated_command = command_for_execution(
command,
self.active_session.as_ref(ctx).shell_type(ctx),
*wait_until_completion,
);
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(),
command: decorated_command,
@@ -295,8 +278,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -359,8 +341,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -391,6 +372,7 @@ impl ShellCommandExecutor {
},
));
}
let command = block.command_with_secrets_unobfuscated(false);
drop(model);
let block_selector = BlockSelector::Id(block_id.clone());
@@ -400,8 +382,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
});
}
@@ -439,7 +420,9 @@ impl ShellCommandExecutor {
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx);
.entry(block_selector.clone())
.or_default()
.push(block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
@@ -511,7 +494,7 @@ impl ShellCommandExecutor {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.prune_closed_senders(&block_selector);
me.control_handback_sender = None;
});
}
@@ -540,13 +523,17 @@ impl ShellCommandExecutor {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
.entry(block_selector.clone())
.or_default()
.push(block_metadata_received_tx);
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
.entry(block_selector.clone())
.or_default()
.push(force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
@@ -620,7 +607,12 @@ impl ShellCommandExecutor {
completed_ts: block.completed_ts().cloned(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
model.is_alt_screen_active(),
model.active_block_id(),
block.id(),
);
let grid_contents = if selected_block_owns_alt_screen {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
@@ -638,7 +630,7 @@ impl ShellCommandExecutor {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted,
}
}
@@ -650,23 +642,50 @@ impl ShellCommandExecutor {
}
}
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
pub(super) fn cancel_execution(
&mut self,
id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> bool {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return;
}
let selector = if active_block
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
let requested_selector = BlockSelector::RequestedCommandId(id.clone());
let requested_block_is_running = requested_selector
.get_block(&terminal_model)
.is_some_and(|block| block.is_active_and_long_running() && !block.finished());
let selector = if requested_block_is_running {
requested_selector
} else {
BlockSelector::Id(active_block.id().clone())
BlockSelector::Id(terminal_model.active_block_id().clone())
};
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
// Cancelling the wait future alone would report cancellation while the process keeps
// running. Terminate the exact requested command before resolving the action as cancelled.
if requested_block_is_running {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
}
if !requested_block_is_running {
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
requested_block_is_running
}
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
}
fn prune_closed_sender_group(
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
selector: &BlockSelector,
) {
if let Some(selector_senders) = senders.get_mut(selector) {
selector_senders.retain(|sender| !sender.is_canceled());
if selector_senders.is_empty() {
senders.remove(selector);
}
}
}
/// Force any in-flight poll for the given long-running command block to resolve
@@ -677,23 +696,28 @@ impl ShellCommandExecutor {
/// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
// at a time.
// Find every pending poll whose selector resolves to this block. Multiple provider polls
// may legitimately wait on the same command and must be refreshed together.
let matching_selector = self
.force_refresh_senders
.keys()
.find(|selector| {
selector
.get_block(&terminal_model)
.is_some_and(|block| block.id() == block_id)
selector.get_block(&terminal_model).is_some_and(|block| {
block.id() == block_id
&& block.is_active_and_long_running()
&& !block.finished()
})
})
.cloned();
drop(terminal_model);
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
return sender.send(()).is_ok();
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
let mut refreshed = false;
for sender in senders {
refreshed |= sender.send(()).is_ok();
}
return refreshed;
}
}
false
@@ -708,6 +732,45 @@ impl ShellCommandExecutor {
}
}
fn command_for_execution(
command: &str,
shell_type: Option<ShellType>,
wait_until_completion: bool,
) -> String {
if !wait_until_completion {
return command.to_string();
}
match shell_type {
// Pager environment variables preserve the command's output and exit status, unlike piping
// through `cat`. Tool-specific variables override user configuration for common pagers.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!(
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; {command})"
),
Some(ShellType::Fish) => format!(
"begin; set -lx PAGER cat; set -lx GIT_PAGER cat; set -lx GH_PAGER cat; set -lx AWS_PAGER cat; set -lx SYSTEMD_PAGER cat; {command}; end"
),
// PowerShell's pipeline host suppresses paging for commands that honor the host stream.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
None => command.to_string(),
}
}
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
command: command.to_string(),
message: format!("terminal is busy running command '{running_command}'"),
})
}
fn selected_block_owns_alt_screen(
is_alt_screen_active: bool,
active_block_id: &BlockId,
selected_block_id: &BlockId,
) -> bool {
is_alt_screen_active && active_block_id == selected_block_id
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector {
Id(BlockId),
@@ -913,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
input: Bytes,
mode: AIAgentPtyWriteMode,
},
CancelExecution,
CancelExecution {
action_id: AIAgentActionId,
},
/// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser {
action_id: AIAgentActionId,
@@ -1,18 +1,80 @@
use std::sync::Arc;
use std::task::Poll;
use async_channel::unbounded;
use futures::channel::oneshot;
use futures::{pin_mut, poll};
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{ActionResult, BlockSelector, ShellCommandExecutor};
use crate::ai::agent::ShellCommandDelay;
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
use super::{
command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
ActionResult, BlockSelector, ShellCommandExecutor,
};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
};
use crate::terminal::event::{
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::AIConversationId;
#[test]
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
let command = "git log -8 --oneline && false";
let decorated = command_for_execution(command, Some(ShellType::Zsh), true);
assert_eq!(
decorated,
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; git log -8 --oneline && false)"
);
assert!(!decorated.contains("| command cat"));
assert_eq!(
command_for_execution(command, Some(ShellType::Zsh), false),
command
);
}
#[test]
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
let result = terminal_busy_execution_error("cargo test", "sleep 120");
assert!(matches!(
result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { command, message }
) if command == "cargo test"
&& message == "terminal is busy running command 'sleep 120'"
));
}
#[test]
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
let active_block_id = BlockId::new();
let selected_block_id = BlockId::new();
assert!(!selected_block_owns_alt_screen(
true,
&active_block_id,
&selected_block_id
));
assert!(selected_block_owns_alt_screen(
true,
&active_block_id,
&active_block_id
));
assert!(!selected_block_owns_alt_screen(
false,
&active_block_id,
&active_block_id
));
}
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
@@ -46,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
let selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx);
executor.block_finished_senders.insert(selector, vec![tx]);
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
@@ -71,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
that map is reserved for precmd (BlockMetadataReceived)"
);
// Precmd event — the senders map should be drained (and since the
// block isn't in the terminal model, the sender is dropped).
// An unrelated precmd cannot resolve this selector, so its waiter must survive.
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
@@ -85,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
});
assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0,
"BlockMetadataReceived should drain the finish senders"
1,
"BlockMetadataReceived must retain unresolved finish senders"
);
});
}
@@ -103,11 +164,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
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 120", "still running");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
@@ -118,15 +182,170 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
assert!(executor.force_refresh_block(&block_id));
assert!(!executor.force_refresh_block(&block_id));
});
assert!(matches!(rx.try_recv(), Ok(Some(()))));
let (tx, _rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
!executor.force_refresh_block(&block_id)
}));
});
}
#[test]
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let action_id = AIAgentActionId::from("requested-command".to_string());
let result_future = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::RequestedCommandId(action_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(result_future);
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert!(matches!(poll!(&mut result_future), Poll::Pending));
terminal_model
.lock()
.simulate_long_running_block("printf done", "done");
let block_id = terminal_model.lock().active_block_id().clone();
terminal_model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_requested_command(
action_id,
None,
AIConversationId::new(),
);
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
assert!(matches!(
result_future.await,
ActionResult::CommandFinished {
block_id: result_block_id,
..
} if result_block_id == block_id
));
});
}
#[test]
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 1", "finished");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let first = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
let second = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(first);
pin_mut!(second);
assert!(matches!(poll!(&mut first), Poll::Pending));
assert!(matches!(poll!(&mut second), Poll::Pending));
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
let first_result = first.await;
let second_result = second.await;
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
assert!(matches!(
second_result,
ActionResult::CommandFinished { .. }
));
});
}
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
BlockCompletedEvent {
block_latency_data: None,
block_type: BlockType::Restored,
num_secrets_obfuscated: 0,
block_index: BlockIndex::zero(),
block_id,
session_id: None,
restored_block_was_local: None,
}
}
#[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures::future::BoxFuture;
use futures::FutureExt;
@@ -6,7 +8,10 @@ use galaxy_cli::agent::Harness;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::{
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
@@ -27,10 +32,38 @@ pub enum StartAgentOutcome {
agent_id: String,
output: String,
},
/// An error occurred while starting the agent.
/// An error occurred while starting or running the agent.
Error(String),
}
/// Determines whether a dispatch receiver acknowledges startup or waits for a
/// direct-provider child to reach a terminal state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartAgentWaitPolicy {
Startup,
Completion,
}
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
match mode {
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
}
}
pub struct StartAgentDispatch {
pub request_id: StartAgentRequestId,
pub receiver: async_channel::Receiver<StartAgentOutcome>,
pub wait_policy: StartAgentWaitPolicy,
pub(super) detached: Arc<AtomicBool>,
}
impl StartAgentDispatch {
pub(super) fn mark_detached(&self) {
self.detached.store(true, Ordering::Release);
}
}
fn invalid_local_child_harness_error(harness_type: &str) -> String {
let harness_name = harness_type.trim();
if harness_name.is_empty() {
@@ -115,17 +148,19 @@ pub struct StartAgentRequest {
}
struct PendingStartAgent {
/// Present for standalone StartAgent tool calls. RunAgents dispatches use
/// the same executor but do not have a one-to-one StartAgent action card.
action_id: Option<AIAgentActionId>,
action_id: AIAgentActionId,
/// Present when RunAgents owns this dispatch. Standalone StartAgent calls
/// use the action id only for their one-to-one inline child panel.
run_agents_child_name: Option<String>,
parent_conversation_id: AIConversationId,
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>,
detached: Arc<AtomicBool>,
/// Direct Bedrock/OpenAI parents do not have a server run id or an
/// orchestration event stream. Keep the tool call open until their local
/// child finishes, then return the child's output inline.
wait_for_completion: bool,
wait_policy: StartAgentWaitPolicy,
}
pub struct StartAgentExecutor {
@@ -158,34 +193,41 @@ impl StartAgentExecutor {
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let direct_provider_panel_link = {
let Some(pending) = self.pending.get_mut(&request_id) else {
let child_link_event = {
let Some(pending) = self.pending.get(&request_id) else {
return;
};
if pending.detached.load(Ordering::Acquire) {
self.pending.remove(&request_id);
return;
}
let pending = self
.pending
.get_mut(&request_id)
.expect("pending request was checked above");
pending.child_conversation_id = Some(child_conversation_id);
if pending.wait_for_completion {
pending.action_id.clone().map(|action_id| {
(
action_id,
pending.parent_conversation_id,
child_conversation_id,
)
if let Some(agent_name) = pending.run_agents_child_name.clone() {
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id: pending.action_id.clone(),
agent_name,
parent_conversation_id: pending.parent_conversation_id,
child_conversation_id,
})
} else if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
Some(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id: pending.action_id.clone(),
parent_conversation_id: pending.parent_conversation_id,
child_conversation_id,
},
)
} else {
None
}
};
if let Some((action_id, parent_conversation_id, child_conversation_id)) =
direct_provider_panel_link
{
ctx.emit(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
},
);
if let Some(event) = child_link_event {
ctx.emit(event);
}
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
}
@@ -271,14 +313,15 @@ impl StartAgentExecutor {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
// A child that reaches `complete_pending_as_error` never obtained an
// agent id, so it failed at the launch stage. Clean up its hidden
// pane + conversation so the orchestration pill bar does not retain a
// dead chip — but only for terminal failures, leaving recoverable
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
// Only startup acknowledgements may clean up a conversation that never
// initialized. Direct-provider completion waits preserve the terminal
// child so its transcript and failure remain inspectable.
let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup)
&& BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.is_some_and(|conversation| {
should_cleanup_failed_child_launch(conversation.status())
});
if should_cleanup {
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
conversation_id: child_conversation_id,
@@ -297,23 +340,49 @@ impl StartAgentExecutor {
else {
return;
};
if let Some(error_msg) = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
return;
}
let wait_for_completion = self
let wait_policy = self
.pending
.get(&request_id)
.is_some_and(|pending| pending.wait_for_completion);
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
return;
}
if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
.map(|pending| pending.wait_policy);
match wait_policy {
Some(StartAgentWaitPolicy::Completion) => match conversation.status() {
ConversationStatus::Success => {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
}
ConversationStatus::Error | ConversationStatus::Cancelled => {
let error_msg = direct_child_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
)
.expect("terminal direct child status should produce an error");
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
}
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => {}
},
Some(StartAgentWaitPolicy::Startup) => {
if let Some(error_msg) = start_agent_startup_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
} else if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
}
}
None => {}
}
}
@@ -346,6 +415,22 @@ impl StartAgentExecutor {
} => {
self.record_child_conversation(*request_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(
"Child agent conversation was removed by the user.".to_string(),
));
}
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
@@ -358,8 +443,6 @@ impl StartAgentExecutor {
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
@@ -400,12 +483,19 @@ impl StartAgentExecutor {
return ActionExecution::InvalidAction;
};
let prompt = prompt.clone();
let version = *version;
let action_id = input.action.id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, version },
));
}
let prompt = prompt.clone();
let action_id = input.action.id.clone();
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
let prompt = compose_leaf_agent_prompt(&prompt);
let (execution_mode, parent_run_id) = match execution_mode {
StartAgentExecutionMode::Local {
harness_type: None,
@@ -531,20 +621,23 @@ impl StartAgentExecutor {
}
};
// In local mode (no parent_run_id), block until the child finishes
// so the parent model receives the child's output as the tool result.
let wait_for_completion = parent_run_id.is_none();
// Local children return their completed work; remote children acknowledge startup and
// continue through the hosted orchestration lifecycle.
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
action_id: Some(action_id),
action_id,
run_agents_child_name: None,
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion,
detached,
wait_policy,
},
);
@@ -589,6 +682,7 @@ impl StartAgentExecutor {
#[allow(clippy::too_many_arguments)]
pub fn dispatch(
&mut self,
action_id: AIAgentActionId,
name: String,
prompt: String,
execution_mode: StartAgentExecutionMode,
@@ -596,19 +690,34 @@ impl StartAgentExecutor {
parent_conversation_id: AIConversationId,
parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> {
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
) -> StartAgentDispatch {
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error));
return StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
};
}
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let prompt = compose_leaf_agent_prompt(&prompt);
self.pending.insert(
request_id,
PendingStartAgent {
action_id: None,
action_id,
run_agents_child_name: Some(name.clone()),
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion: parent_run_id.is_none(),
detached: detached.clone(),
wait_policy,
},
);
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
@@ -622,7 +731,92 @@ impl StartAgentExecutor {
parent_run_id,
},
)));
receiver
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
pub fn reattach(
&mut self,
action_id: AIAgentActionId,
name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
wait_policy: StartAgentWaitPolicy,
ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch {
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
action_id,
run_agents_child_name: Some(name),
parent_conversation_id,
child_conversation_id: Some(child_conversation_id),
sender,
detached: detached.clone(),
wait_policy,
},
);
self.record_child_conversation(request_id, child_conversation_id, ctx);
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
/// Detaches one exact dispatch. If its launch callback is already queued,
/// the shared marker prevents that callback from linking a late child.
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
let Some(pending) = self.pending.remove(&request_id) else {
return false;
};
pending.detached.store(true, Ordering::Release);
true
}
/// Test-only lookup for request ownership without exposing executor internals.
#[cfg(test)]
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
self.pending.contains_key(&request_id)
}
pub fn cancel_dispatches_for_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> usize {
let request_ids = self
.pending
.iter()
.filter_map(|(request_id, pending)| {
(pending.parent_conversation_id == conversation_id
&& &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,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
self.cancel_dispatches_for_action(conversation_id, action_id);
}
pub(super) fn preprocess_action(
@@ -666,7 +860,7 @@ fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
}
}
fn start_agent_error_message_for_status(
fn start_agent_startup_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
@@ -701,6 +895,26 @@ fn start_agent_error_message_for_status(
}
}
fn direct_child_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
match status {
ConversationStatus::Error => Some(
error_message
.filter(|message| !message.trim().is_empty())
.unwrap_or("Child agent failed")
.to_string(),
),
ConversationStatus::Cancelled => Some("Child agent was cancelled by the user.".to_string()),
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Success
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => None,
}
}
impl Entity for StartAgentExecutor {
type Event = StartAgentExecutorEvent;
}
@@ -715,6 +929,14 @@ pub enum StartAgentExecutorEvent {
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A RunAgents child conversation is available for live status and
/// navigation in the owning action card.
RunAgentsChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A child agent failed at the launch stage (never started a server-side
/// run). The owning terminal view removes its hidden pane and conversation
/// so the orchestration pill bar does not retain a dead chip.
@@ -28,6 +28,37 @@ impl Entity for CapturedDirectProviderChildLinks {
type Event = ();
}
#[derive(Default)]
struct CapturedStartAgentPrompts(Vec<String>);
impl Entity for CapturedStartAgentPrompts {
type Event = ();
}
#[derive(Default)]
struct CapturedRunAgentsChildLinks(
Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>,
);
impl Entity for CapturedRunAgentsChildLinks {
type Event = ();
}
fn capture_start_agent_prompts(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedStartAgentPrompts> {
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CreateAgent(request) = event {
captured.0.push(request.prompt.clone());
}
});
});
captured
}
fn capture_direct_provider_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
@@ -52,6 +83,32 @@ fn capture_direct_provider_child_links(
captured
}
fn capture_run_agents_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedRunAgentsChildLinks> {
let captured = app.add_model(|_| CapturedRunAgentsChildLinks::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
captured.0.push((
action_id.clone(),
agent_name.clone(),
*parent_conversation_id,
*child_conversation_id,
));
}
});
});
captured
}
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
@@ -79,6 +136,192 @@ fn build_start_agent_action_with_prompt(
}
}
#[test]
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_start_agent_prompts(&mut app, &executor);
let root_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: root_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
captured.read(&app, |captured, _ctx| {
assert_eq!(captured.0.len(), 1);
assert!(captured.0[0].contains("You are a leaf worker"));
assert!(
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
);
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
});
});
}
#[test]
fn execute_denies_start_agent_from_child_conversation() {
App::test((), |mut app| async move {
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 child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
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: child_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, .. }
)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn dispatch_denies_child_conversation_defense_in_depth() {
App::test((), |mut app| async move {
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 child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-action".to_string()),
"grandchild".to_string(),
"Do more work".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
child_conversation_id,
None,
ctx,
)
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn local_execution_waits_for_completion() {
assert_eq!(
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
StartAgentWaitPolicy::Completion
);
}
#[test]
fn detach_dispatch_rejects_late_child_callback() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some(PARENT_RUN_ID.to_string()),
ctx,
)
});
assert!(executor.update(&mut app, |executor, _| {
executor.detach_dispatch(dispatch.request_id)
}));
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
dispatch.request_id,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
assert!(dispatch.receiver.try_recv().is_err());
});
}
#[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
@@ -543,6 +786,426 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
});
}
struct PendingDirectProviderChild {
action_id: AIAgentActionId,
parent_conversation_id: AIConversationId,
history_model: ModelHandle<BlocklistAIHistoryModel>,
executor: ModelHandle<StartAgentExecutor>,
captured_cleanup: ModelHandle<CapturedCleanupEvents>,
direct_links: ModelHandle<CapturedDirectProviderChildLinks>,
run_agents_links: ModelHandle<CapturedRunAgentsChildLinks>,
terminal_view_id: EntityId,
child_conversation_id: AIConversationId,
dispatch: StartAgentDispatch,
}
fn dispatch_pending_direct_provider_child(app: &mut App) -> PendingDirectProviderChild {
initialize_history_persistence_for_tests(app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured_cleanup = app.add_model(|_| CapturedCleanupEvents::default());
captured_cleanup.update(app, |_, ctx| {
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
captured.0.push(*conversation_id);
}
});
});
let direct_links = capture_direct_provider_child_links(app, &executor);
let run_agents_links = capture_run_agents_child_links(app, &executor);
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(app, |executor, ctx| {
executor.dispatch(
action_id.clone(),
"child".to_string(),
"Investigate the failure".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
parent_conversation_id,
None,
ctx,
)
});
let child_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(app, |history_model, ctx| {
history_model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
PendingDirectProviderChild {
action_id,
parent_conversation_id,
history_model,
executor,
captured_cleanup,
direct_links,
run_agents_links,
terminal_view_id,
child_conversation_id,
dispatch,
}
}
#[test]
fn direct_provider_nonterminal_states_remain_pending_until_cancelled() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
assert_eq!(state.dispatch.wait_policy, StartAgentWaitPolicy::Completion);
for status in [
ConversationStatus::Blocked {
blocked_action: "Waiting for user input".to_string(),
},
ConversationStatus::TransientError,
ConversationStatus::WaitingForEvents,
] {
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
status,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
});
}
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Cancelled,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent was cancelled by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
});
}
#[test]
fn direct_provider_error_preserves_child_for_inspection() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other("Child execution failed", false)),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed"
));
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.history_model.read(&app, |history_model, _| {
assert!(history_model
.conversation(&state.child_conversation_id)
.is_some());
});
});
}
#[test]
fn 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(parent_conversation_id, &action.id);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
history_model.read(&app, |history, _| {
assert_eq!(
history
.conversation(&child_conversation_id)
.expect("child should remain in history")
.status(),
&ConversationStatus::InProgress
);
});
let AnyActionExecution::Async { execute_future, .. } = execution else {
panic!("expected async StartAgent execution");
};
let _ = execute_future.await;
});
}
#[test]
fn cancelling_duplicate_action_id_detaches_only_the_matching_conversation() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let terminal_view_id = EntityId::new();
let first_conversation = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let second_conversation = 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 first = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: first_conversation,
},
ctx,
)
.into()
});
let second = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: second_conversation,
},
ctx,
)
.into()
});
assert!(matches!(first, AnyActionExecution::Async { .. }));
assert!(matches!(second, AnyActionExecution::Async { .. }));
executor.update(&mut app, |executor, _| {
executor.cancel_execution(first_conversation, &action.id);
assert_eq!(executor.pending.len(), 1);
assert_eq!(
executor
.pending
.values()
.next()
.unwrap()
.parent_conversation_id,
second_conversation
);
});
});
}
#[test]
fn removing_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.remove_conversation(
state.child_conversation_id,
state.terminal_view_id,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn deleting_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.delete_conversation(
state.child_conversation_id,
Some(state.terminal_view_id),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn run_agents_dispatch_publishes_only_run_agents_child_link() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.direct_links.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.run_agents_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
state.action_id.clone(),
"child".to_string(),
state.parent_conversation_id,
state.child_conversation_id,
)]
);
});
});
}
#[test]
fn reattach_reuses_persisted_child_without_launching_another_agent() {
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 captured_prompts = capture_start_agent_prompts(&mut app, &executor);
let captured_links = capture_run_agents_child_links(&mut app, &executor);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.reattach(
action_id.clone(),
"child".to_string(),
parent_conversation_id,
child_conversation_id,
StartAgentWaitPolicy::Completion,
ctx,
)
});
assert_eq!(dispatch.wait_policy, StartAgentWaitPolicy::Completion);
assert!(matches!(
dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
captured_prompts.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
captured_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
action_id,
"child".to_string(),
parent_conversation_id,
child_conversation_id,
)]
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Success,
ctx,
);
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Completed { agent_id, .. })
if agent_id == child_conversation_id.to_string()
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
App::test((), |mut app| async move {
@@ -596,7 +1259,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() {
.pending
.get(&FIRST_REQUEST_ID)
.expect("direct child should remain pending until completion");
assert!(pending.wait_for_completion);
assert_eq!(pending.wait_policy, StartAgentWaitPolicy::Completion);
});
history_model.update(&mut app, |history_model, ctx| {
@@ -62,6 +62,9 @@ fn initialize_upload_artifact_test(
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
+374 -3
View File
@@ -3,7 +3,10 @@ use std::sync::Arc;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
@@ -13,6 +16,44 @@ fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
})
}
fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResult {
AIAgentActionResult {
id: AIAgentActionId::from(id.to_owned()),
task_id: TaskId::new("task".to_owned()),
result,
}
}
fn action(id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_string()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new("task".to_string()),
requires_result: true,
tool_name: Some("init_project".to_string()),
}
}
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(7),
},
calls: call_ids
.iter()
.map(|call_id| galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: (*call_id).to_string(),
name: "init_project".to_string(),
arguments: serde_json::json!({}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
})
.collect(),
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
@@ -35,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
count
}
#[test]
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
assert_eq!(correlations.len(), 2);
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
assert_eq!(correlations[0].1.call_id, "first");
let error = provider_action_correlations(
&[action("second"), action("first")],
conversation_id,
&batch,
)
.unwrap_err();
assert_eq!(
error,
ProviderActionQueueError::ActionSetMismatch {
expected: vec!["first".to_string(), "second".to_string()],
received: vec!["second".to_string(), "first".to_string()],
}
);
}
#[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase =
@@ -71,6 +140,47 @@ fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
}
#[test]
fn automatic_retries_only_target_actions_deferred_as_not_ready() {
let conversation_id = AIConversationId::new();
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut tracker = NotReadyActionTracker::default();
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::Automatic,
);
assert!(tracker.should_retry(conversation_id, &action_id));
assert!(!ActionExecutionInitiator::Automatic.is_user_initiated());
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::User,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NeedsConfirmation,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::WaitingOnSharer,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
assert!(ActionExecutionInitiator::User.is_user_initiated());
}
#[test]
fn finished_results_stay_in_original_action_order() {
let action_order = HashMap::from([
@@ -84,8 +194,7 @@ fn finished_results_stay_in_original_action_order() {
make_action_result("second"),
];
finished_results
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
sort_action_results_by_order(&mut finished_results, &action_order);
assert_eq!(
finished_results[0].id,
@@ -100,3 +209,265 @@ fn finished_results_stay_in_original_action_order() {
AIAgentActionId::from("third".to_owned())
);
}
#[test]
fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
let success = domain_tool_result(
&action_result("success", AIAgentActionResultType::InitProject),
false,
);
let failure = domain_tool_result(
&action_result(
"failure",
AIAgentActionResultType::Grep(GrepResult::Error("boom".to_string())),
),
false,
);
let cancelled_result = action_result(
"cancelled",
AIAgentActionResultType::Grep(GrepResult::Cancelled),
);
let cancelled = domain_tool_result(&cancelled_result, false);
let denied = domain_tool_result(&cancelled_result, true);
assert_eq!(success.status, ToolResultStatus::Success);
assert_eq!(failure.status, ToolResultStatus::Error);
assert_eq!(cancelled.status, ToolResultStatus::Cancelled);
assert_eq!(denied.status, ToolResultStatus::Denied);
assert_eq!(success.call_id, "success");
assert_eq!(failure.call_id, "failure");
assert_eq!(cancelled.call_id, "cancelled");
assert_eq!(denied.call_id, "cancelled");
assert!(!cancelled.content.contains("Permission denied"));
assert!(denied.content.contains("Permission denied by the user"));
}
#[test]
fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
let result = action_result(
"read-call",
AIAgentActionResultType::ReadFiles(ReadFilesResult::Success {
files: vec![FileContext::new(
"/workspace/src/lib.rs".to_string(),
AnyFileContent::StringContent("pub fn answer() -> u8 { 42 }".to_string()),
None,
None,
)],
}),
);
let result = domain_tool_result(&result, false);
assert_eq!(result.status, ToolResultStatus::Success);
assert_eq!(result.call_id, "read-call");
assert!(result.content.contains("/workspace/src/lib.rs"));
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
}
#[test]
fn action_permission_kinds_match_the_safety_boundary() {
assert_eq!(
permission_kind_for_action(&AIAgentActionType::Grep {
queries: vec!["needle".to_string()],
path: ".".to_string(),
}),
PermissionKind::Read
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::InitProject),
PermissionKind::Write
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::RequestCommandOutput {
command: "cargo test".to_string(),
is_read_only: Some(true),
is_risky: Some(false),
wait_until_completion: true,
uses_pager: Some(false),
rationale: None,
citations: Vec::new(),
}),
PermissionKind::Execute
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::CallMCPTool {
server_id: None,
name: "tool".to_string(),
input: serde_json::json!({}),
}),
PermissionKind::ExternalTool
);
}
#[test]
fn denied_permission_event_resolves_the_pending_call() {
let action = action("call-1");
let ToolEvent::PermissionResolved {
request_id,
call_id,
decision,
} = permission_denied_tool_event(&action)
else {
panic!("expected a permission resolution event");
};
assert_eq!(request_id, "permission:call-1");
assert_eq!(call_id, "call-1");
assert_eq!(
decision,
PermissionDecision::Denied {
reason: Some("Permission denied by the user.".to_string()),
}
);
}
#[test]
fn provider_owned_denial_suppresses_duplicate_completion() {
assert!(!should_emit_tool_completion(true, true));
assert!(should_emit_tool_completion(true, false));
assert!(should_emit_tool_completion(false, true));
}
#[test]
fn only_rejecting_a_blocked_action_is_a_permission_denial() {
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Blocked),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Queued),
));
assert!(!is_permission_denial(
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
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));
}
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
) -> Option<Box<dyn Element>> {
if !item.available_in().is_available_for_cli()
|| !item.available_to_session_viewer(shared_status, false)
|| !item.is_available(app)
{
return None;
}
@@ -2016,6 +2017,7 @@ impl AgentInputFooter {
});
if !item.available_in().is_available_for_agent_view()
|| !item.available_to_session_viewer(shared_status, is_cloud_mode)
|| !item.is_available(app)
{
return None;
}
@@ -178,6 +178,8 @@ impl AgentToolbarItemKind {
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
// Retain the enum variant so existing toolbar settings still deserialize.
Self::ShareSession => false,
_ => true,
}
}
@@ -215,11 +217,6 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage,
Self::ModelSelector,
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -247,11 +244,6 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -322,3 +314,7 @@ impl From<ContextChipKind> for AgentToolbarItemKind {
Self::ContextChip(kind)
}
}
#[cfg(test)]
#[path = "toolbar_item_tests.rs"]
mod tests;
@@ -0,0 +1,15 @@
use super::AgentToolbarItemKind;
#[test]
fn legacy_share_session_setting_remains_deserializable() {
let item: AgentToolbarItemKind =
serde_json::from_str("\"ShareSession\"").expect("legacy setting should deserialize");
assert_eq!(item, AgentToolbarItemKind::ShareSession);
}
#[test]
fn share_session_is_not_offered_by_defaults_or_configurator() {
assert!(!AgentToolbarItemKind::default_right().contains(&AgentToolbarItemKind::ShareSession));
assert!(!AgentToolbarItemKind::all_available().contains(&AgentToolbarItemKind::ShareSession));
}
+153 -39
View File
@@ -30,6 +30,7 @@ use base64::Engine as _;
use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent};
use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
}
}
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
if is_streaming
&& has_output
&& !self.user_toggled_while_streaming
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
{
self.expand();
}
self.sync_finished_state(is_finished);
if is_finished {
if let CollapsibleExpansionState::Expanded {
scroll_pinned_to_bottom,
..
} = &mut self.expansion_state
{
*scroll_pinned_to_bottom = false;
}
}
}
/// Applies orchestration message display behavior after streaming finishes.
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
let should_auto_collapse = self.should_auto_collapse_on_finish();
@@ -2174,6 +2196,19 @@ impl AIBlock {
}
match action {
AIAgentAction {
id: action_id,
action: AIAgentActionType::RequestFileEdits { title, file_edits },
..
} => {
self.ensure_requested_edit_view(
action_id,
title,
file_edits.clone(),
output.server_output_id.clone(),
ctx,
);
}
AIAgentAction {
id: action_id,
action:
@@ -2323,6 +2358,32 @@ impl AIBlock {
// Register element state for reasoning messages and track summarization timing.
for message in &output.messages {
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
let is_finished = matches!(
activity.status,
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
);
let has_output = activity
.output
.as_deref()
.is_some_and(|output| !output.is_empty());
let state = self
.collapsible_block_states
.entry(message.id.clone())
.or_insert_with(|| {
if is_streaming && has_output {
CollapsibleElementState::default()
} else {
CollapsibleElementState::collapsed()
}
});
state.sync_runtime_activity(is_streaming, is_finished, has_output);
}
if let AIAgentOutputMessageType::Reasoning {
finished_duration, ..
} = &message.message
@@ -2608,6 +2669,7 @@ impl AIBlock {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)
@@ -2712,7 +2774,7 @@ impl AIBlock {
},
..
} => {
self.handle_requested_edit_complete(
self.ensure_requested_edit_view(
id,
title,
file_edits.clone(),
@@ -3232,7 +3294,7 @@ impl AIBlock {
});
}
fn handle_requested_edit_complete(
fn ensure_requested_edit_view(
&mut self,
action_id: &AIAgentActionId,
title: &Option<String>,
@@ -3240,6 +3302,10 @@ impl AIBlock {
server_output_id: Option<ServerOutputId>,
ctx: &mut ViewContext<Self>,
) {
if self.requested_edits.contains_key(action_id) {
return;
}
let identifiers = AIIdentifiers {
client_conversation_id: Some(self.client_ids.conversation_id),
client_exchange_id: Some(self.client_ids.client_exchange_id),
@@ -3295,14 +3361,6 @@ impl AIBlock {
ctx,
)
});
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, _| {
executor.register_requested_edits(action_id, &view);
});
// If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload.
if self.action_model.as_ref(ctx).is_view_only() {
let active_session = self.active_session.as_ref(ctx);
@@ -3459,7 +3517,17 @@ impl AIBlock {
});
self.requested_edits
.insert(action_id.clone(), RequestedEdit::new(view));
.insert(action_id.clone(), RequestedEdit::new(view.clone()));
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, ctx| {
executor.register_requested_edits(action_id, &view, ctx);
});
self.action_model.update(ctx, |action_model, ctx| {
action_model.retry_not_ready_action(action_id, self.client_ids.conversation_id, ctx);
});
if self.model.request_type(ctx).is_passive() {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
@@ -3510,7 +3578,10 @@ impl AIBlock {
}
// Set the state based on the action status from the action model
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id)
@@ -3605,6 +3676,7 @@ impl AIBlock {
RequestedCommandViewEvent::Accepted => {
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
action_id,
view.as_ref(ctx).command_text().to_string(),
ctx,
@@ -3623,7 +3695,10 @@ impl AIBlock {
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
// We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block).
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = {
let terminal_model = self.terminal_model.lock();
terminal_model
@@ -3822,7 +3897,7 @@ impl AIBlock {
if self
.action_model
.as_ref(ctx)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_blocked())
{
ctx.focus(&view);
@@ -4206,7 +4281,10 @@ impl AIBlock {
// but it's not incorrect to populate if it is, and we rely on this for
// for restored conversations because action model events don't re-fire
// after the view is created.
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id);
let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
if let Some(view) = self.search_codebase_view.get(action_id) {
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
@@ -4640,7 +4718,11 @@ impl AIBlock {
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
self.requested_action_ids
.iter()
.filter_map(|id| self.action_model.as_ref(app).get_action_status(id))
.filter_map(|id| {
self.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, id)
})
.any(|status| status.is_blocked())
}
@@ -4666,7 +4748,12 @@ impl AIBlock {
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
let action_id = event.action_id();
if me.is_finished() || !me.requested_action_ids.contains(action_id) {
if event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id)
|| me.is_finished()
|| !me.requested_action_ids.contains(action_id)
{
// Technically, this subscription should be unregistered after `is_finished` is
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
// call, so early return here if this is errantly being called.
@@ -4674,7 +4761,7 @@ impl AIBlock {
}
match event {
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction { .. } => {
match &me.autonomy_setting_speedbump {
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
action_id: speedbump_action_id,
@@ -4744,7 +4831,7 @@ impl AIBlock {
_ => {}
}
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
@@ -4760,7 +4847,7 @@ impl AIBlock {
{
let should_collapse = action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.is_none_or(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { exit_code, .. },
@@ -4775,7 +4862,9 @@ impl AIBlock {
}
if let Some(view) = me.search_codebase_view.get(action_id) {
let new_status = action_model.as_ref(ctx).get_action_status(action_id);
let new_status = action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id);
view.update(ctx, |view, ctx| {
view.update_status(new_status);
ctx.notify();
@@ -4784,7 +4873,9 @@ impl AIBlock {
// Create subagent panel state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) =
action_model.as_ref(ctx).get_action_status(action_id)
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id)
{
if let AIAgentActionResultType::StartAgent(
crate::ai::agent::StartAgentResult::Success { agent_id, .. },
@@ -4806,7 +4897,11 @@ impl AIBlock {
let action_statuses = me
.requested_action_ids
.iter()
.filter_map(|id| action_model.as_ref(ctx).get_action_status(id))
.filter_map(|id| {
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, id)
})
.collect_vec();
// Detecting links on SearchCodebase tool call outputs
@@ -4839,7 +4934,9 @@ impl AIBlock {
view.update_render_read_file_args(
&me.find_state,
files.clone(),
action_model.as_ref(ctx).get_action_status(action_id),
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id),
);
ctx.notify();
})
@@ -4849,7 +4946,9 @@ impl AIBlock {
// Open the AI document pane when documents are created or edited
if let Some(action_result) =
action_model.as_ref(ctx).get_action_result(action_id)
action_model
.as_ref(ctx)
.get_action_result(me.client_ids.conversation_id, action_id)
{
match &action_result.result {
AIAgentActionResultType::CreateDocuments(
@@ -4901,7 +5000,7 @@ impl AIBlock {
}
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction(action_id) => {
BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
// Update search codebase view status when action is queued
if let Some(view) = me.search_codebase_view.get(action_id) {
view.update(ctx, |view, ctx| {
@@ -4929,7 +5028,8 @@ impl AIBlock {
}
}
BlocklistAIActionEvent::InitProject(_)
BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_) => {}
}
});
@@ -5608,7 +5708,9 @@ impl AIBlock {
/// This hides their keybindings in the UI and makes them less interactive.
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
self.action_model.update(ctx, |action_model, ctx| {
for action in action_model.get_pending_actions() {
for action in
action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
{
if let Some(edit) = self.requested_edits.get(&action.id) {
edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
@@ -5661,7 +5763,12 @@ impl AIBlock {
.view
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(&action_id, command_text, ctx);
action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
&action_id,
command_text,
ctx,
);
});
ctx.notify();
}
@@ -5689,12 +5796,11 @@ impl AIBlock {
/// Finds the undismissed passive code diff across all pending actions.
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
let all_pending_actions = self.action_model.as_ref(app).get_pending_actions();
// Find any RequestFileEdits action that has a corresponding passive code diff view.
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
all_pending_actions
.iter()
self.action_model
.as_ref(app)
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
.find_map(|action| match &action.action {
AIAgentActionType::RequestFileEdits {
file_edits: _,
@@ -5734,7 +5840,10 @@ impl AIBlock {
.is_none_or(|output| {
output.get().actions().last().is_none_or(|action| {
let is_streaming = self.model.status(app).is_streaming();
let status = self.action_model.as_ref(app).get_action_status(&action.id);
let status = self
.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, &action.id);
is_streaming || status.is_some_and(|status| status.is_running())
})
})
@@ -5761,7 +5870,7 @@ impl AIBlock {
.any(|(action_id, requested_command)| {
self.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_running())
&& requested_command.view.as_ref(app).is_header_expanded()
})
@@ -5861,7 +5970,10 @@ impl AIBlock {
return String::new();
};
let output = output.get();
output.format_for_copy(Some(self.action_model.as_ref(app)))
output.format_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
)
}
/// Gets AI output text for copying from the preceding user query until the next user query
@@ -5916,8 +6028,10 @@ impl AIBlock {
// Collect all AI outputs from start_idx to end_idx (exclusive)
let mut combined_result = Vec::new();
for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
let formatted_output =
exchange.format_output_for_copy(Some(self.action_model.as_ref(app)));
let formatted_output = exchange.format_output_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
);
if !formatted_output.is_empty() {
combined_result.push(formatted_output);
}
@@ -7089,7 +7203,7 @@ impl TypedActionView for AIBlock {
let Some(result) = self
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(self.client_ids.conversation_id, action_id)
.map(Arc::clone)
else {
continue;
+4 -2
View File
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
let is_cancelled = self
.action_model
.as_ref(app)
.get_action_status(&action.id)
.get_action_status(self.conversation_id, &action.id)
.is_some_and(|status| status.is_cancelled());
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app)
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|| (is_latest_exchange
&& matches!(
message,
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::WebSearch(_)
))
}
+376 -64
View File
@@ -15,6 +15,7 @@ use crate::ai::agent::{
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
@@ -40,9 +41,14 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
initial_requested_command_conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
/// Prevents a monitor turn that ended with prose and no tool call from recursively
/// generating nudges. A real snapshot/action result resets this so the next turn can be
/// nudged again if it stalls in the same way.
monitor_nudge_sent: bool,
completion: Option<PendingCommandCompletion>,
}
@@ -52,6 +58,7 @@ struct PendingCommandCompletion {
initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String,
completed_command: RunningCommand,
exit_code: i32,
final_turn_started: bool,
}
@@ -67,7 +74,7 @@ impl UserTakeOverReason {
pub fn transfer_reason(&self) -> Option<&str> {
match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None,
Self::Manual | Self::Stop => None,
}
}
}
@@ -161,12 +168,25 @@ impl CLISubagentController {
return;
};
me.advance_completed_subagents(*conversation_id, ctx);
me.ensure_monitor_continues(*conversation_id, ctx);
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned();
@@ -176,9 +196,21 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
@@ -190,12 +222,13 @@ impl CLISubagentController {
}
BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id,
conversation_id,
..
} => {
let action_result = me
.action_model
.as_ref(ctx)
.get_action_result(finished_action_id);
.get_action_result(*conversation_id, finished_action_id);
let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| {
matches!(
@@ -215,38 +248,47 @@ impl CLISubagentController {
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
if matches_active_requested_command(
*conversation_id,
finished_action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
active_block.update_is_agent_blocked(false);
let active_command_action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
let active_command_action_id =
active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id {
me.active_subagents_by_block
let state = me
.active_subagents_by_block
.entry(snapshot_block_id.clone())
.or_default()
.last_snapshot_at = Some(Instant::now());
.or_default();
state.last_snapshot_at = Some(Instant::now());
state.monitor_nudge_sent = false;
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref()
!= Some(finished_action_id)
|| !matches_requested_command_identity(
*conversation_id,
finished_action_id,
state.initial_requested_command_conversation_id,
state.initial_requested_command_action_id.as_ref(),
)
});
}
drop(terminal_model);
if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
me.advance_completed_subagent(&block_id, ctx);
}
}
_ => (),
@@ -265,6 +307,8 @@ impl CLISubagentController {
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let should_skip_completion_assessment =
!should_request_completion_assessment(block.long_running_control_state());
let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() {
@@ -294,6 +338,7 @@ impl CLISubagentController {
requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false,
},
exit_code,
final_turn_started: false,
})
}
@@ -310,17 +355,70 @@ impl CLISubagentController {
};
drop(terminal_model);
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
return;
};
if subagent_state.last_snapshot_at.is_some() {
let provider_accepted_completion = completion.as_ref().is_some_and(|completion| {
let provider_completion = PendingProviderCommandCompletion::new(
completion.completed_command.block_id.clone(),
completion.initial_requested_command_action_id.clone(),
completion.completed_command.command.clone(),
completion.completed_command.grid_contents.clone(),
completion.exit_code,
);
me.controller.update(ctx, |controller, ctx| {
controller.offer_provider_command_completion(
completion.conversation_id,
provider_completion,
ctx,
)
})
});
let has_last_snapshot = me
.active_subagents_by_block
.get(&block_id)
.is_some_and(|state| state.last_snapshot_at.is_some());
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
subagent_state.completion = completion;
if subagent_state.completion.is_none() {
if provider_accepted_completion {
// The provider controller owns deactivation after it applies the queued
// completion at a safe run boundary.
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
return;
}
// A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also
// clean up the in-memory monitor state so the stopped subagent cannot linger in
// the UI or intercept later refreshes.
if should_skip_completion_assessment {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
let has_completion = {
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
else {
return;
};
subagent_state.completion = completion;
subagent_state.completion.is_some()
};
if !has_completion {
log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata"
);
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
me.advance_completed_subagent(&block_id, ctx);
@@ -359,10 +457,10 @@ impl CLISubagentController {
}
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self
let Some(completion) = self
.active_subagents_by_block
.get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone())))
.and_then(|state| state.completion.as_ref().cloned())
else {
return;
};
@@ -380,14 +478,18 @@ impl CLISubagentController {
}
if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx);
self.finish_subagent(
block_id,
Some(completion.conversation_id),
completion.initial_requested_command_action_id,
ctx,
);
return;
}
let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment(
completion.conversation_id,
task_id,
completion.prompt,
completion.completed_command,
ctx,
@@ -404,38 +506,125 @@ impl CLISubagentController {
}
}
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let Some(completion) = state.completion else {
/// A monitor turn that returns only prose has no action result to trigger the normal
/// action-follow-up path. Nudge that monitor once with the live command context so a model
/// that acknowledged the first snapshot without polling gets another chance to inspect it.
fn ensure_monitor_continues(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(block_id) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| {
conversation.all_tasks().find_map(|task| {
let block_id = task.cli_subagent_block_id()?;
let state = self.active_subagents_by_block.get(&block_id)?;
if state.task_id.as_ref() != Some(task.id()) || state.completion.is_some() {
return None;
}
let last_exchange_has_action = task.last_exchange().is_some_and(|exchange| {
exchange
.output_status
.output()
.is_some_and(|output| output.get().actions().next().is_some())
});
should_nudge_monitor_turn(last_exchange_has_action, state.monitor_nudge_sent)
.then_some(block_id)
})
})
else {
return;
};
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.deactivate_cli_subagent_task_for_conversation(
block_id,
completion.conversation_id,
)
});
if let Err(error) = deactivate_result {
log::error!(
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
if self
.controller
.as_ref(ctx)
.has_active_provider_run(conversation_id)
|| self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return;
}
let command_is_still_agent_controlled = {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| {
block.is_active_and_long_running()
&& block.is_agent_in_control()
&& block.ai_conversation_id() == Some(conversation_id)
})
};
if !command_is_still_agent_controlled {
return;
}
if let Some(state) = self.active_subagents_by_block.get_mut(&block_id) {
state.monitor_nudge_sent = true;
}
self.controller.update(ctx, |controller, ctx| {
controller.send_cli_monitor_nudge(conversation_id, ctx);
});
}
fn finish_subagent(
&mut self,
block_id: &BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let conversation_id = conversation_id.or_else(|| {
state
.completion
.as_ref()
.map(|completion| completion.conversation_id)
});
let initial_requested_command_action_id = initial_requested_command_action_id
.or_else(|| {
state
.completion
.as_ref()
.and_then(|completion| completion.initial_requested_command_action_id.clone())
})
.or(state.initial_requested_command_action_id);
if let Some(conversation_id) = conversation_id {
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
});
if let Err(error) = deactivate_result {
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id),
initial_requested_command_action_id: completion.initial_requested_command_action_id,
conversation_id,
initial_requested_command_action_id,
});
if let Some(agent_view_controller) = &self.agent_view_controller {
if let (Some(agent_view_controller), Some(conversation_id)) =
(&self.agent_view_controller, conversation_id)
{
agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id);
== Some(conversation_id);
if is_this_inline_conversation {
controller.exit_agent_view(ctx);
}
@@ -469,11 +658,18 @@ impl CLISubagentController {
///
/// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
self.active_subagents_by_block
pub fn track_requested_command(
&mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
let state = self
.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_action_id = Some(action_id.clone());
.or_default();
state.initial_requested_command_conversation_id = Some(conversation_id);
state.initial_requested_command_action_id = Some(action_id.clone());
}
/// Force the currently in-flight poll for the given long-running command block to
@@ -609,13 +805,7 @@ impl CLISubagentController {
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
controller.resume_conversation(conversation_id, resume_context, ctx);
});
}
}
@@ -726,6 +916,10 @@ impl CLISubagentController {
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_conversation_id = Some(conversation_id);
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
@@ -874,6 +1068,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. },
)
| AIAgentActionResultType::WriteToLongRunningShellCommand(
@@ -919,3 +1114,120 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
| AIAgentActionResultType::WaitForEvents(_) => None,
}
}
fn should_request_completion_assessment(
control_state: Option<&LongRunningCommandControlState>,
) -> bool {
!control_state
.and_then(LongRunningCommandControlState::user_take_over_reason)
.is_some_and(UserTakeOverReason::is_stop)
}
fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent: bool) -> bool {
!last_exchange_has_action && !monitor_nudge_sent
}
fn matches_active_requested_command(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
active_conversation_id: Option<AIConversationId>,
active_requested_command_id: Option<&AIAgentActionId>,
) -> bool {
active_conversation_id == Some(event_conversation_id)
&& active_requested_command_id == Some(event_action_id)
}
fn matches_requested_command_identity(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
requested_command_conversation_id: Option<AIConversationId>,
requested_command_action_id: Option<&AIAgentActionId>,
) -> bool {
requested_command_conversation_id == Some(event_conversation_id)
&& requested_command_action_id == Some(event_action_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
}
+10
View File
@@ -1,5 +1,6 @@
use std::time::Duration;
use galaxy_agent_core::RuntimeActivity;
use galaxy_terminal::model::escape_sequences;
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
});
assert!(!should_retain_task_output_message(&poll, false));
assert!(should_retain_task_output_message(&poll, true));
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
id: "acp-tool".to_owned(),
title: "Inspect repository".to_owned(),
status: None,
output: None,
});
assert!(!should_retain_task_output_message(&runtime_activity, false));
assert!(should_retain_task_output_message(&runtime_activity, true));
}
+5 -1
View File
@@ -149,7 +149,11 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
let output = output.get();
output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.message {
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) {
if let Some(status) = self.conversation_id(app).and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, &action.id)
}) {
return status.is_blocked().then_some(action.clone());
}
}
+21 -4
View File
@@ -328,10 +328,27 @@ impl BlocklistAIStatusBar {
},
);
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..)
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (),
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction {
conversation_id, ..
}
| BlocklistAIActionEvent::FinishedAction {
conversation_id, ..
} if me
.active_exchange_model
.as_ref()
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)) =>
{
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. }
| BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_)
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => {}
});
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => {
+2
View File
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
contents.add_child(output::render(
output::Props {
conversation_id: self.client_ids.conversation_id,
model: self.model.as_ref(),
state_handles: &self.state_handles,
action_buttons: &self.action_buttons,
@@ -1375,6 +1376,7 @@ impl AIAgentInput {
app,
)),
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
@@ -3687,6 +3687,7 @@ pub(super) fn query_prefix_highlight_len(
match input {
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let orchestrator_agent_id = props
.model
.conversation(app)
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else {
+189 -28
View File
@@ -16,12 +16,13 @@ use ai::agent::action::{
};
use ai::agent::file_locations::group_file_contexts_for_display;
use ai::skills::{ParsedSkill, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use galaxy_core::channel::ChannelState;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable,
MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap,
@@ -55,6 +56,7 @@ use super::{
};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
@@ -84,18 +86,22 @@ use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestion
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
use crate::ai::blocklist::inline_action::inline_action_header::{
HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::{
render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction,
};
use crate::ai::blocklist::inline_action::requested_command::RequestedCommand;
use crate::ai::blocklist::inline_action::requested_command::{
format_command_text, RequestedCommand, REQUESTED_COMMAND_BODY_VERTICAL_PADDING,
VIEWING_COMMAND_DETAIL_MESSAGE,
};
use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView;
use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView;
use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView;
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons;
@@ -131,9 +137,14 @@ use crate::{AIAgentTodoList, FeatureFlag};
const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?";
fn should_render_requested_edit(action_status: Option<&AIActionStatus>) -> bool {
!action_status.is_some_and(AIActionStatus::is_preprocessing)
}
/// Data required to render the AI block output component.
#[derive(Copy, Clone)]
pub(crate) struct Props<'a> {
pub(crate) conversation_id: AIConversationId,
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
pub(super) state_handles: &'a AIBlockStateHandles,
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
@@ -400,6 +411,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
} if !are_all_text_sections_empty(sections) => {
text_section_index += sections.len();
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
if !matches!(
activity.status,
Some(RuntimeActivityStatus::Completed)
| Some(RuntimeActivityStatus::Failed)
) {
should_render_footer = false;
should_render_suggestions = false;
}
if let Some(rendered_activity) =
render_runtime_activity(output_message, activity, props, app)
{
output_items.add_child(rendered_activity);
}
}
AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::RequestCommandOutput { .. },
id,
@@ -412,7 +438,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -452,7 +478,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
// checks if the read file action result is completed and successful.
@@ -541,13 +567,12 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
id,
..
}) => {
let action_status =
props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_preprocessing = action_status
.clone()
.is_some_and(|status| status.is_preprocessing());
if !is_preprocessing && !status.is_streaming() {
if should_render_requested_edit(action_status.as_ref()) {
if let Some(requested_edit) = props.requested_edits.get(id) {
// Don't render the requested edit if the diffs are empty for passive code diffs.
if request_type.is_passive_code_diff()
@@ -635,7 +660,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props
.action_model
.as_ref(app)
.get_action_status(id)
.get_action_status(props.conversation_id, id)
.as_ref()
.is_some_and(|status| status.is_done());
if !is_action_done {
@@ -1262,6 +1287,106 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
output_items.finish()
}
fn render_runtime_activity(
output_message: &AIAgentOutputMessage,
activity: &RuntimeActivity,
props: Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let state = props.collapsible_block_states.get(&output_message.id)?;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let output = activity
.output
.as_deref()
.filter(|output| !output.is_empty());
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let icon = match activity.status.as_ref() {
Some(RuntimeActivityStatus::Pending) => icons::pending_icon(appearance),
Some(RuntimeActivityStatus::InProgress) => icons::yellow_running_icon(appearance),
Some(RuntimeActivityStatus::Completed) => inline_action_icons::green_check_icon(appearance),
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance),
Some(RuntimeActivityStatus::Other(_)) | None => icons::gray_circle_icon(appearance),
};
let title = if is_expanded {
VIEWING_COMMAND_DETAIL_MESSAGE.to_owned()
} else {
format_command_text(&activity.title)
};
let mut header = HeaderConfig::new(title, app)
.with_selectable_text()
.with_icon(icon)
.with_corner_radius_override(if is_expanded && output.is_some() {
CornerRadius::with_top(Radius::Pixels(8.))
} else {
CornerRadius::with_all(Radius::Pixels(8.))
});
if !is_expanded {
header = header.with_font_family(appearance.monospace_font_family());
}
if output.is_some() {
let message_id = output_message.id.clone();
let command = activity.title.clone();
let expansion =
ExpandedConfig::new(is_expanded, state.expansion_toggle_mouse_state.clone())
.with_toggle_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id.clone(),
));
})
.with_right_click_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
command: command.clone(),
});
});
header = header.with_interaction_mode(InteractionMode::ManuallyExpandable(expansion));
}
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Clipped::new(header.render(app)).finish());
if let Some(output) = output {
let body = render_requested_action_body_text(
output.into(),
appearance.monospace_font_family(),
app,
)
.finish();
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
if let Some(scrollable) = render_scrollable_collapsible_content(
&output_message.id,
state,
body,
is_streaming,
320.,
) {
content.add_child(
Container::new(scrollable)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(REQUESTED_COMMAND_BODY_VERTICAL_PADDING)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
}
Some(render_tool_pane_shell(
content.finish(),
false,
is_expanded,
false,
app,
))
}
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
if FeatureFlag::AgentView.is_enabled() {
return false;
@@ -1358,7 +1483,10 @@ fn render_search_codebase(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
@@ -1859,7 +1987,10 @@ fn render_read_files(
parsed_skill: Option<&ai::skills::ParsedSkill>,
action_index: usize,
) -> Box<dyn Element> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app);
let formatted_files =
render_read_files_text(props.into(), file_names, app, appearance, action_index);
@@ -1976,7 +2107,10 @@ fn maybe_render_edit_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -1986,7 +2120,7 @@ fn maybe_render_edit_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2013,7 +2147,10 @@ fn maybe_render_create_document(
id: &AIAgentActionId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -2023,7 +2160,7 @@ fn maybe_render_create_document(
let agent_action_results = props
.action_model
.as_ref(app)
.get_action_result(id)
.get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult {
@@ -2326,7 +2463,7 @@ fn render_suggest_new_conversation(
let status = props
.action_model
.as_ref(app)
.get_action_status(action_id)
.get_action_status(props.conversation_id, action_id)
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
@@ -2434,7 +2571,10 @@ fn create_formatted_text_for_grep(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2538,7 +2678,10 @@ fn create_formatted_text_for_file_glob(
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id);
let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status
.as_ref()
.is_some_and(|status| status.is_cancelled());
@@ -2639,7 +2782,10 @@ fn render_file_retrieval_tool(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
@@ -2756,7 +2902,10 @@ fn render_read_mcp_resource(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(name, app);
@@ -2833,11 +2982,14 @@ fn render_upload_artifact(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let result = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.and_then(|result| match &result.result {
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
_ => None,
@@ -2896,7 +3048,7 @@ fn render_use_computer(
let has_screenshot = props
.action_model
.as_ref(app)
.get_action_result(action_id)
.get_action_result(props.conversation_id, action_id)
.is_some_and(|result| {
matches!(
&result.result,
@@ -2942,7 +3094,10 @@ fn render_request_computer_use(
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id);
let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(&request.task_summary, app);
@@ -3523,7 +3678,13 @@ pub fn action_icon<V: View>(
app: &AppContext,
) -> galaxyui::elements::Icon {
let appearance = Appearance::as_ref(app);
let status = action_model.as_ref(app).get_action_status(action_id);
let status = ai_block_model
.conversation_id(app)
.and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, action_id)
});
match status {
Some(status) => match status {
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
@@ -11,12 +11,23 @@ use watcher::HomeDirectoryWatcher;
use super::{
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
should_render_requested_edit,
};
use crate::ai::agent::UploadArtifactResult;
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::skills::SkillManager;
use crate::settings::AISettings;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
#[test]
fn requested_edits_render_as_soon_as_preprocessing_finishes() {
assert!(!should_render_requested_edit(Some(
&AIActionStatus::Preprocessing
)));
assert!(should_render_requested_edit(Some(&AIActionStatus::Blocked)));
assert!(should_render_requested_edit(None));
}
#[test]
fn format_upload_artifact_text_includes_request_details() {
let request = UploadArtifactRequest {
+48
View File
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
));
}
#[test]
fn completed_runtime_activity_stays_collapsed_until_opened() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn streaming_runtime_activity_expands_when_output_arrives() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: false,
scroll_pinned_to_bottom: true
}
));
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
}
#[test]
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn orchestration_show_and_collapse_collapses_after_finish() {
let mut state = default_collapsible_state_for_orchestration_message(
+25 -1
View File
@@ -188,13 +188,37 @@ impl BlocklistAIContextModel {
);
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
if matches!(
event,
LLMPreferencesEvent::UpdatedActiveAgentModeLLM
| LLMPreferencesEvent::UpdatedAvailableLLMs
) {
let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported =
llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id));
#[cfg(not(target_family = "wasm"))]
let desired_backend =
llm_prefs.agent_backend_for_active_model(Some(me.terminal_surface_id), ctx);
if !vision_supported {
me.clear_pending_images(ctx);
}
// ACP and provider histories have different owners. When the
// selected model crosses that boundary, make the next prompt a
// fresh conversation instead of silently sending it through
// the backend that owned the existing conversation.
#[cfg(not(target_family = "wasm"))]
{
let selected_backend = me
.selected_conversation(ctx)
.map(|conversation| conversation.agent_backend().clone());
if selected_backend.is_some_and(|backend| backend != desired_backend) {
me.set_pending_query_state_for_new_conversation(
AgentViewEntryOrigin::ConversationSelector,
ctx,
);
}
}
}
});
File diff suppressed because it is too large Load Diff
@@ -52,11 +52,15 @@ impl PendingResponseStreams {
.collect()
}
/// Attempts to inject a plain-text follow-up into the active ACP turn.
pub fn has_stream(&self, stream_id: &ResponseStreamId) -> bool {
self.streams.contains_key(stream_id)
}
/// Attempts to inject a plain-text follow-up into an active steerable runtime.
///
/// Returning `None` leaves the caller free to use the normal
/// cancel-and-queue path without dropping the user's message.
pub fn try_steer_acp_stream_for_conversation(
pub fn try_steer_runtime_for_conversation(
&self,
conversation_id: AIConversationId,
display_text: String,
@@ -71,7 +75,7 @@ impl PendingResponseStreams {
let model_id = stream.as_ref(app).llm_id().clone();
stream
.as_ref(app)
.try_steer_acp(display_text)
.try_steer_runtime(display_text)
.then(|| (stream_id.clone(), model_id))
}
@@ -87,6 +91,14 @@ impl PendingResponseStreams {
self.streams.insert(stream_id, stream);
}
pub fn register_additional_stream(
&mut self,
stream_id: ResponseStreamId,
stream: ModelHandle<ResponseStream>,
) {
self.streams.insert(stream_id, stream);
}
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id);
}
@@ -136,11 +148,13 @@ impl PendingResponseStreams {
false
} else {
for response_stream in streams_to_cancel.into_iter() {
log::info!(
crate::ai::tool_diagnostics::tool_debug!(
"Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
reason={reason}"
);
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
}
response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
File diff suppressed because it is too large Load Diff
@@ -1,86 +1,22 @@
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
use warp_multi_agent_api::response_event::stream_finished;
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
use super::{is_interactive_remote_command, stream_finished_llm_finished};
#[test]
fn pre_action_failures_retry() {
assert_eq!(
recovery_action(false, true, true, true, true),
RecoveryAction::RetryNow
);
// Resume eligibility is irrelevant pre-actions.
assert_eq!(
recovery_action(false, true, true, false, true),
RecoveryAction::RetryNow
);
}
#[test]
fn pre_action_failures_wait_for_connectivity_when_offline() {
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
fn response_finish_reason_reports_whether_the_llm_completed() {
assert!(stream_finished_llm_finished(&None));
assert!(stream_finished_llm_finished(&Some(
stream_finished::Reason::Done(stream_finished::Done {})
)));
assert!(stream_finished_llm_finished(&Some(
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
)));
assert!(!stream_finished_llm_finished(&Some(
stream_finished::Reason::Other(stream_finished::Other {})
)));
assert!(!stream_finished_llm_finished(&Some(
stream_finished::Reason::LlmUnavailable(stream_finished::LlmUnavailable {})
)));
}
#[test]
@@ -354,7 +354,7 @@ impl BlocklistAIController {
if self
.action_model
.as_ref(ctx)
.get_action_result(&result.id)
.get_action_result(conversation_id, &result.id)
.is_none()
{
self.action_model.update(ctx, |action_model, ctx| {
@@ -148,6 +148,19 @@ impl SlashCommandRequest {
is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == conversation_id),
};
if controller.should_block_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
ctx,
) {
controller.log_blocked_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
is_queued_prompt,
ctx,
);
return;
}
if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress(
active_conversation_id,
@@ -181,7 +194,6 @@ impl SlashCommandRequest {
entrypoint,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
is_queued_prompt,
ctx,
) {
File diff suppressed because it is too large Load Diff
+204 -52
View File
@@ -33,14 +33,16 @@ use crate::ai::agent::conversation::{
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus,
CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost,
Suggestions,
AIAgentAction, AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError,
RequestCost, Suggestions,
};
use crate::ai::artifacts::Artifact;
use crate::ai::document::ai_document_model::AIDocumentModel;
#[cfg(not(target_family = "wasm"))]
use crate::ai::llms::LLMPreferences;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::input_suggestions::HistoryOrder;
use crate::persistence::model::{
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
@@ -561,6 +563,44 @@ impl BlocklistAIHistoryModel {
conversation.write_updated_conversation_state(ctx);
}
pub(crate) fn persist_active_provider_run_json(
&mut self,
conversation_id: AIConversationId,
snapshot: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.set_active_provider_run_json(snapshot);
conversation.write_updated_conversation_state(ctx);
Ok(())
}
pub(crate) fn rebind_provider_projection(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.rebind_provider_projection(
task_id,
exchange_id,
response_stream_id,
terminal_surface_id,
ctx,
)?;
Ok(())
}
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
return;
@@ -1182,6 +1222,87 @@ impl BlocklistAIHistoryModel {
});
}
fn configured_agent_backend(
terminal_surface_id: EntityId,
is_viewing_shared_session: bool,
is_cli_agent_transcript: bool,
ctx: &AppContext,
) -> AgentBackend {
if is_viewing_shared_session
|| is_cli_agent_transcript
|| !cfg!(unix)
|| !FeatureFlag::AgentClientProtocol.is_enabled()
{
return AgentBackend::Provider;
}
let settings = AISettings::as_ref(ctx);
if !*settings.acp_enabled.value() {
return AgentBackend::Provider;
}
#[cfg(not(target_family = "wasm"))]
if let Some(llm_preferences) = ctx.try_get_singleton_model_as_ref::<LLMPreferences>() {
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
}
let providers = settings.enabled_acp_providers();
let [provider] = providers.as_slice() else {
return AgentBackend::Provider;
};
let agent_id = provider.agent_id.trim();
let agent_id = if agent_id.is_empty() {
"codex"
} else {
agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint =
acp_launch_fingerprint(agent_id, &provider.command, &provider.args);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
provider_id: provider.id.clone(),
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
&provider.config_options,
),
})
}
/// Reconciles a conversation without agent output with the currently enabled local runtime.
///
/// Agent views can create their initial conversation before the user changes runtime settings,
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
/// either case use ACP without mixing successful provider output into an ACP-owned history.
pub(crate) fn refresh_conversation_backend_without_output(
&mut self,
conversation_id: AIConversationId,
ctx: &AppContext,
) {
let Some(conversation) = self.conversation(&conversation_id) else {
return;
};
let Some(terminal_surface_id) = self.terminal_surface_id_for_conversation(&conversation_id)
else {
return;
};
let agent_backend = Self::configured_agent_backend(
terminal_surface_id,
conversation.is_viewing_shared_session(),
conversation.is_cli_agent_transcript(),
ctx,
);
if conversation.agent_backend() == &agent_backend {
return;
}
if let Some(conversation) = self.conversation_mut(&conversation_id) {
conversation.set_agent_backend_if_no_output(agent_backend);
}
}
/// Starts a new conversation in the given terminal surface's history, effectively marking the
/// existing conversation (if any) as completed.
///
@@ -1197,55 +1318,12 @@ impl BlocklistAIHistoryModel {
is_cli_agent_transcript: bool,
ctx: &mut ModelContext<Self>,
) -> AIConversationId {
let agent_backend = if !is_viewing_shared_session
&& !is_cli_agent_transcript
&& cfg!(unix)
&& FeatureFlag::AgentClientProtocol.is_enabled()
{
let settings = AISettings::as_ref(ctx);
if *settings.acp_enabled.value() {
let configured_agent_id = settings.acp_agent_id.value().trim();
let agent_id = if configured_agent_id.is_empty() {
"codex"
} else {
configured_agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint = acp_launch_fingerprint(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) = LLMPreferences::as_ref(ctx)
.selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(
&agent.config_options,
)
})
.unwrap_or_default(),
})
} else {
AgentBackend::Provider
}
} else {
AgentBackend::Provider
};
let agent_backend = Self::configured_agent_backend(
terminal_surface_id,
is_viewing_shared_session,
is_cli_agent_transcript,
ctx,
);
let mut new_conversation = AIConversation::new_with_agent_backend(
is_viewing_shared_session,
is_cli_agent_transcript,
@@ -1322,7 +1400,21 @@ impl BlocklistAIHistoryModel {
ctx: &mut ModelContext<Self>,
) {
if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) {
#[cfg(not(target_family = "wasm"))]
let remote_log_context =
remote_status_log_context(conversation, &status, error.as_ref());
conversation.update_status_with_error(status, error, terminal_surface_id, ctx);
#[cfg(not(target_family = "wasm"))]
if let Some(context) = remote_log_context {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level: RemoteLogLevel::Info,
message: "Agent conversation status changed".to_string(),
context,
},
);
}
}
}
@@ -1598,6 +1690,7 @@ impl BlocklistAIHistoryModel {
let conversation_data = AgentConversationData {
agent_backend: source_conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
reverted_action_ids,
@@ -1762,6 +1855,7 @@ impl BlocklistAIHistoryModel {
// be recomputed based on the retained exchanges in a follow-up.
let conversation_data = AgentConversationData {
agent_backend: conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids,
@@ -1852,6 +1946,21 @@ impl BlocklistAIHistoryModel {
Ok(())
}
pub fn apply_domain_tool_proposal(
&mut self,
response_stream_id: &ResponseStreamId,
conversation_id: AIConversationId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
self.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?
.apply_domain_tool_proposal(response_stream_id, terminal_surface_id, action, ctx)?;
Ok(())
}
pub fn update_conversation_cost_and_usage_for_request(
&mut self,
conversation_id: AIConversationId,
@@ -2755,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
// Placeholder authoritative.
agent_backend: placeholder.agent_backend().clone(),
// Active process-local provider runs cannot be merged from a cloud transcript.
active_provider_run_json: None,
// Cloud authoritative.
server_conversation_token: cloud_conversation
.server_conversation_token()
@@ -2817,6 +2929,46 @@ fn agent_id_key_from_persisted_data(conversation_data: &AgentConversationData) -
conversation_data.run_id.as_deref()
}
#[cfg(not(target_family = "wasm"))]
fn remote_status_log_context(
conversation: &AIConversation,
new_status: &ConversationStatus,
error: Option<&RenderableAIError>,
) -> Option<serde_json::Value> {
let prev_status = conversation.status();
if prev_status == new_status {
return None;
}
Some(serde_json::json!({
"event": "agent_conversation_status_changed",
"conversation_id": conversation.id().to_string(),
"parent_conversation_id": conversation.parent_conversation_id().map(|id| id.to_string()),
"agent_id": conversation.orchestration_agent_id(),
"agent_name": conversation.agent_name(),
"harness_type": conversation.orchestration_harness_type(),
"is_child": conversation.parent_conversation_id().is_some(),
"is_remote_child": conversation.is_remote_child(),
"previous_status": conversation_status_label(prev_status),
"new_status": conversation_status_label(new_status),
"new_status_is_terminal": new_status.is_done(),
"error": error.map(remote_logging::sanitize_error),
}))
}
#[cfg(not(target_family = "wasm"))]
fn conversation_status_label(status: &ConversationStatus) -> &'static str {
match status {
ConversationStatus::InProgress => "in_progress",
ConversationStatus::Success => "success",
ConversationStatus::Error => "error",
ConversationStatus::TransientError => "transient_error",
ConversationStatus::Cancelled => "cancelled",
ConversationStatus::Blocked { .. } => "blocked",
ConversationStatus::WaitingForEvents => "waiting_for_events",
}
}
/// Whether an `UpdatedConversationStatus` event represents a restoration
/// (the conversation was re-loaded for a terminal surface; the underlying
/// `ConversationStatus` did not change) or a real status set, in which case
+295 -2
View File
@@ -21,8 +21,10 @@ use crate::ai::agent::conversation::{
ServerAIConversationMetadata,
};
use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput,
RenderableAIError, Shared, TransientNetworkErrorKind, UserQueryMode,
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchange, AIAgentExchangeId,
AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
AIAgentOutputStatus, AIAgentText, AIAgentTextSection, AgentOutputText, FinishedAIAgentOutput,
MessageId, RenderableAIError, RunningCommand, Shared, TransientNetworkErrorKind, UserQueryMode,
};
use crate::ai::ambient_agents::{
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
@@ -78,6 +80,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
assert_eq!(
conversation.agent_backend(),
&AgentBackend::Acp(AcpConversationData {
provider_id: "legacy".to_string(),
agent_id: "codex".to_string(),
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
session_id: None,
@@ -88,6 +91,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
});
}
#[test]
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(false, ctx)
.expect("ACP setting should update");
});
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |model, ctx| {
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.read(&app, |model, _| {
assert_eq!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
&AgentBackend::Provider
);
});
history_model.update(&mut app, |model, _| {
let now = Local::now();
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(AIAgentExchange {
id: AIAgentExchangeId::new(),
input: Vec::new(),
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error {
output: None,
error: RenderableAIError::other("No AI provider configured", true),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("none"),
request_cost: None,
coding_model_id: LLMId::from("none"),
cli_agent_model_id: LLMId::from("none"),
computer_use_model_id: LLMId::from("none"),
response_initiator: None,
});
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(true, ctx)
.expect("ACP setting should update");
});
history_model.update(&mut app, |model, ctx| {
model.refresh_conversation_backend_without_output(conversation_id, ctx);
});
history_model.read(&app, |model, _| {
assert!(matches!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
AgentBackend::Acp(_)
));
});
});
}
/// Helper function to create a PersistedAIInput for testing
fn create_persisted_query(
query_text: &str,
@@ -144,6 +223,101 @@ fn repeated_command_steering_reuses_the_active_cli_subtask() {
});
}
#[test]
fn provider_tool_proposal_creates_exchange_for_tool_first_cli_turn() {
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 stream_id = ResponseStreamId::new_for_test();
let action_id = AIAgentActionId::from("monitor-tool-call".to_owned());
let (conversation_id, cli_task_id, action) =
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let root_task_id = model
.conversation(&conversation_id)
.expect("conversation should exist")
.get_root_task_id()
.clone();
model
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(root_task_id, Vec::new())]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_view_id,
ctx,
)
.expect("root response exchange should be recorded");
model.initialize_output_for_response_stream(
&stream_id,
conversation_id,
terminal_view_id,
warp_multi_agent_api::response_event::StreamInit {
request_id: "provider-request".to_owned(),
conversation_id: "provider-conversation".to_owned(),
run_id: "provider-run".to_owned(),
},
ctx,
);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
BlockId::new(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let action = AIAgentAction {
id: action_id.clone(),
task_id: cli_task_id.clone(),
action: AIAgentActionType::FileGlob {
patterns: vec!["*.rs".to_owned()],
path: None,
},
requires_result: true,
tool_name: Some("file_glob".to_owned()),
};
model
.apply_domain_tool_proposal(
&stream_id,
conversation_id,
terminal_view_id,
action.clone(),
ctx,
)
.expect("tool-first CLI proposal should attach to a lazy exchange");
(conversation_id, cli_task_id, action)
});
history_model.read(&app, |model, _| {
let conversation = model
.conversation(&conversation_id)
.expect("conversation should exist");
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI subtask should exist");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
conversation.exchange_id_for_action(&action.id),
cli_task.last_exchange().map(|exchange| exchange.id)
);
assert!(conversation.contains_action(&action.id));
});
});
}
#[test]
fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
App::test((), |mut app| async move {
@@ -193,6 +367,125 @@ fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
});
}
#[test]
fn completed_command_assessment_survives_cli_subtask_deactivation_on_root() {
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 block_id = BlockId::new();
let assessment_output = "The command completed successfully.";
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let monitor_exchange =
create_exchange_with_query("Check the command status.", Local::now(), None);
let monitor_exchange_id = monitor_exchange.id;
let conversation = model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
conversation
.append_task_exchange_for_test(
&cli_task_id,
monitor_exchange,
terminal_view_id,
ctx,
)
.expect("monitor exchange should be appended to the CLI task");
let now = Local::now();
let assessment_exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Assess the completed command.".to_string(),
context: Arc::from([]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}],
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Success {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("assessment-output".to_string()),
message: AIAgentOutputMessageType::Text(AIAgentText {
sections: vec![AIAgentTextSection::PlainText {
text: AgentOutputText::from(assessment_output.to_string()),
}],
}),
citations: vec![],
}],
..Default::default()
}),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
response_initiator: None,
};
let assessment_exchange_id = assessment_exchange.id;
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(assessment_exchange);
model
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
.expect("CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI task should be retained after deactivation");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
cli_task.last_exchange().map(|exchange| exchange.id),
Some(monitor_exchange_id)
);
let root_exchange = conversation
.latest_visible_exchange()
.expect("root assessment output should remain visible");
assert_eq!(root_exchange.id, assessment_exchange_id);
assert!(matches!(
root_exchange.input.as_slice(),
[AIAgentInput::CommandCompletionAssessment { .. }]
));
assert!(root_exchange.input[0].display_query().is_none());
assert_eq!(
root_exchange.format_output_for_copy(None),
assessment_output
);
});
});
}
#[test]
fn monitoring_a_different_block_preserves_completed_cli_task_history() {
App::test((), |mut app| async move {
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
};
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
if event.action_id() != me.action_id() {
if event.action_id() != me.action_id()
|| event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.conversation_id)
{
return;
}
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
/// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
let action_model = self.action_model.as_ref(app);
if let Some(status) = action_model.get_action_status(self.action_id()) {
if let Some(status) = action_model.get_action_status(self.conversation_id, self.action_id())
{
return Some(status);
}
@@ -366,7 +366,7 @@ pub enum CodeDiffState {
/// The diff is received, but is queued for interaction behind another action.
Queued,
/// The user is reviewing (and possibly editing) the code diff.
/// Unlike requested commands, a [`CodeDiffView`] is only created upon stream completion.
/// The view is created as soon as the requested edit is present in streaming output.
WaitingForUser,
/// If the payload is some, the code diff was accepted but the individual file changes have not
/// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs
@@ -695,12 +695,26 @@ impl CodeDiffView {
session_platform,
ctx,
);
let action_id = (*action_id).clone();
ctx.subscribe_to_model(
&action_model,
move |me, action_model, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => {
match action_model.as_ref(ctx).get_action_status(&me.action_id) {
BlocklistAIActionEvent::FinishedAction {
action_id: event_action_id,
conversation_id: event_conversation_id,
..
} if !me.is_complete()
&& *event_action_id == me.action_id
&& me.identifiers.client_conversation_id == Some(*event_conversation_id) =>
{
let Some(conversation_id) = me.identifiers.client_conversation_id else {
return;
};
match action_model
.as_ref(ctx)
.get_action_status(conversation_id, &me.action_id)
{
Some(AIActionStatus::Blocked) => {
me.state = CodeDiffState::WaitingForUser;
ctx.notify();
@@ -16,5 +16,6 @@ pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(crate) mod suggested_unit_tests;
pub(super) mod summarization;
pub(crate) mod tool_pane;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -175,30 +175,32 @@ impl OrchestrationEditState {
self.model_id.clear();
}
}
pub fn from_run_agents_fields(
model_id: &str,
harness_type: &str,
execution_mode: &RunAgentsExecutionMode,
) -> Self {
Self {
let execution_mode = match execution_mode {
RunAgentsExecutionMode::Local | RunAgentsExecutionMode::Remote { .. } => {
RunAgentsExecutionMode::Local
}
};
let mut state = Self {
model_id: model_id.to_string(),
harness_type: harness_type.to_string(),
execution_mode: execution_mode.clone(),
execution_mode,
auth_secret_selection: AuthSecretSelection::Unset,
}
};
state.sanitize_for_local_execution();
state
}
pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self {
let execution_mode = match &config.execution_mode {
OrchestrationExecutionMode::Local => RunAgentsExecutionMode::Local,
OrchestrationExecutionMode::Remote {
environment_id,
worker_host,
} => RunAgentsExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
computer_use_enabled: false,
},
OrchestrationExecutionMode::Local | OrchestrationExecutionMode::Remote { .. } => {
RunAgentsExecutionMode::Local
}
};
let mut state = Self {
model_id: config.model_id.clone(),
@@ -206,30 +208,17 @@ impl OrchestrationEditState {
execution_mode,
auth_secret_selection: AuthSecretSelection::Unset,
};
if matches!(state.execution_mode, RunAgentsExecutionMode::Local) {
state.sanitize_for_local_execution();
}
state.sanitize_for_local_execution();
state
}
/// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching
/// to Cloud (unsupported combination).
/// Galaxy only supports local child agents, so any mode selection is normalized to Local.
pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) {
if is_remote {
if self.harness_type.eq_ignore_ascii_case("opencode") {
self.harness_type = "oz".to_string();
}
if !self.execution_mode.is_remote() {
self.execution_mode = RunAgentsExecutionMode::Remote {
environment_id: String::new(),
worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(),
computer_use_enabled: false,
};
}
} else {
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
log::warn!("Ignoring remote orchestration selection because Galaxy is local-only");
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
pub fn set_environment_id(&mut self, environment_id: String) {
@@ -251,27 +240,17 @@ impl OrchestrationEditState {
}
/// Returns `Some(reason)` if Accept / Apply must be disabled.
/// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses.
pub fn accept_disabled_reason(&self) -> Option<&'static str> {
match &self.execution_mode {
RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type)
.and_then(local_harness_product_disabled_message),
RunAgentsExecutionMode::Remote { .. }
if self.harness_type.eq_ignore_ascii_case("opencode") =>
{
Some(
"OpenCode is not supported on Cloud yet. Switch to Local or pick a different harness.",
)
RunAgentsExecutionMode::Remote { .. } => {
Some("Galaxy only supports local child-agent orchestration.")
}
RunAgentsExecutionMode::Remote { .. } => None,
}
}
/// Fills in empty fields from the approved orchestration config.
/// When the LLM omits harness/model/execution_mode to inherit from
/// the active config, the raw request arrives with defaults (empty
/// harness, empty model, Local mode). This resolves those to the
/// config values so the UI shows the intended settings.
/// Fills empty model and harness fields from the approved config while keeping execution local.
pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) {
if self.harness_type.is_empty() && !config.harness_type.is_empty() {
self.harness_type = config.harness_type.clone();
@@ -279,67 +258,24 @@ impl OrchestrationEditState {
if self.model_id.is_empty() && !config.model_id.is_empty() {
self.model_id = config.model_id.clone();
}
if !self.execution_mode.is_remote() && config.execution_mode.is_remote() {
self.execution_mode = Self::from_orchestration_config(config).execution_mode;
}
if matches!(self.execution_mode, RunAgentsExecutionMode::Local) {
self.sanitize_for_local_execution();
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
/// Unconditionally overrides model, harness, and execution mode
/// from the approved orchestration config. The plan config is the
/// user-approved source of truth — the LLM's run_agents call may
/// omit or set these differently, but the config always wins.
///
/// `computer_use_enabled` is preserved from the current state when
/// both sides are Remote, since it is a per-call flag set by the LLM.
/// Applies the approved model and harness while keeping execution local.
pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) {
self.model_id = config.model_id.clone();
self.harness_type = config.harness_type.clone();
let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) {
(
RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
},
OrchestrationExecutionMode::Remote { .. },
) => Some(*computer_use_enabled),
_ => None,
};
self.execution_mode = Self::from_orchestration_config(config).execution_mode;
if let (
Some(cue),
RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
},
) = (preserve_computer_use, &mut self.execution_mode)
{
*computer_use_enabled = cue;
}
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
}
/// Converts to a native `OrchestrationConfig` for storage / match.
/// Converts to a local-only native `OrchestrationConfig` for storage / match.
pub fn to_orchestration_config(&self) -> OrchestrationConfig {
let execution_mode = match &self.execution_mode {
RunAgentsExecutionMode::Local => OrchestrationExecutionMode::Local,
RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
..
} => OrchestrationExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
},
};
OrchestrationConfig {
model_id: self.model_id.clone(),
harness_type: self.harness_type.clone(),
execution_mode,
execution_mode: OrchestrationExecutionMode::Local,
}
}
}
@@ -360,7 +296,6 @@ pub struct OrchestrationPickerHandles<A: OrchestrationControlAction> {
/// auth-secret types.
pub auth_secret_picker: Option<ViewHandle<Dropdown<A>>>,
pub local_toggle: MouseStateHandle,
pub cloud_toggle: MouseStateHandle,
}
impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
@@ -372,7 +307,6 @@ impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
host_picker: None,
auth_secret_picker: None,
local_toggle: MouseStateHandle::default(),
cloud_toggle: MouseStateHandle::default(),
}
}
}
@@ -1805,7 +1739,6 @@ impl Element for AdaptivePickerRow {
// ── Render helpers ──────────────────────────────────────────────────
pub fn render_mode_toggle<A: OrchestrationControlAction>(
is_remote: bool,
handles: &OrchestrationPickerHandles<A>,
appearance: &Appearance,
active_segment_bg: Option<Fill>,
@@ -1822,27 +1755,18 @@ pub fn render_mode_toggle<A: OrchestrationControlAction>(
let local_segment = render_segment_button::<A>(
"Local",
!is_remote,
true,
A::execution_mode_toggled(false),
handles.local_toggle.clone(),
appearance,
active_segment_bg,
);
let cloud_segment = render_segment_button::<A>(
"Cloud",
is_remote,
A::execution_mode_toggled(true),
handles.cloud_toggle.clone(),
appearance,
active_segment_bg,
);
let segment_outer_bg = galaxy_core::ui::theme::color::internal_colors::fg_overlay_2(theme);
let segments_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Expanded::new(1.0, cloud_segment).finish())
.with_child(Expanded::new(1.0, local_segment).finish())
.finish();
let segmented_control = Container::new(segments_row)
@@ -6,18 +6,6 @@ use super::{
OrchestrationEditState,
};
fn remote_claude_state() -> OrchestrationEditState {
OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
)
}
fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model_id.to_string(),
@@ -26,10 +14,44 @@ fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
}
}
fn remote_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig {
model_id: model_id.to_string(),
harness_type: harness_type.to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
}
}
fn remote_mode() -> RunAgentsExecutionMode {
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: true,
}
}
#[test]
fn from_orchestration_config_preserves_local_claude() {
fn run_agents_remote_mode_is_normalized_to_local() {
let state = OrchestrationEditState::from_run_agents_fields("sonnet", "claude", &remote_mode());
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(should_show_harness_picker(&state));
assert!(!should_show_auth_secret_picker(&state));
}
#[test]
fn remote_orchestration_config_is_normalized_to_local() {
let state =
OrchestrationEditState::from_orchestration_config(&local_config("claude", "sonnet"));
OrchestrationEditState::from_orchestration_config(&remote_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
@@ -39,66 +61,24 @@ fn from_orchestration_config_preserves_local_claude() {
}
#[test]
fn harness_picker_stays_visible_for_local_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
fn remote_toggle_remains_local() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Local,
);
assert!(should_show_harness_picker(&state));
}
#[test]
fn harness_picker_stays_visible_for_remote_mode() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(true);
assert!(should_show_harness_picker(&state));
}
#[test]
fn from_orchestration_config_preserves_remote_claude() {
let state = OrchestrationEditState::from_orchestration_config(&OrchestrationConfig {
model_id: "sonnet".to_string(),
harness_type: "claude".to_string(),
execution_mode: OrchestrationExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
},
});
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Remote {
ref environment_id,
ref worker_host,
computer_use_enabled: false,
} if environment_id == "env-1" && worker_host == "warp"
RunAgentsExecutionMode::Local
));
}
#[test]
fn toggle_to_local_sanitizes_disabled_codex() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"gpt-5",
"codex",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
state.toggle_execution_mode_to_remote(false);
fn local_normalization_sanitizes_disabled_harnesses() {
let state = OrchestrationEditState::from_run_agents_fields("gpt-5", "codex", &remote_mode());
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
@@ -109,18 +89,11 @@ fn toggle_to_local_sanitizes_disabled_codex() {
}
#[test]
fn toggle_to_local_preserves_claude() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "warp".to_string(),
computer_use_enabled: false,
},
);
fn resolve_from_remote_config_inherits_fields_but_stays_local() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.toggle_execution_mode_to_remote(false);
state.resolve_from_config(&remote_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
@@ -131,27 +104,21 @@ fn toggle_to_local_preserves_claude() {
}
#[test]
fn accept_disabled_reason_allows_local_claude_product() {
let state = OrchestrationEditState::from_run_agents_fields(
"auto",
"claude",
&RunAgentsExecutionMode::Local,
);
assert_eq!(state.accept_disabled_reason(), None);
}
fn approved_remote_config_override_stays_local() {
let mut state = OrchestrationEditState::from_run_agents_fields("auto", "oz", &remote_mode());
#[test]
fn resolve_from_config_preserves_local_claude() {
let mut state =
OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
state.override_from_approved_config(&remote_config("claude", "sonnet"));
state.resolve_from_config(&local_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(matches!(
state.to_orchestration_config().execution_mode,
OrchestrationExecutionMode::Local
));
}
#[test]
@@ -163,32 +130,29 @@ fn resolve_from_config_sanitizes_disabled_local_codex() {
assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, "");
assert!(matches!(
state.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(state.accept_disabled_reason(), None);
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_named() {
let mut state = remote_claude_state();
fn local_mode_does_not_expose_managed_auth_secret() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string());
assert_eq!(state.auth_secret_name(), Some("my-key"));
state.select_create_new_auth_secret();
// `CreatingNew` (distinct from `Unset`) blocks Accept and isn't re-seeded.
assert!(matches!(
state.auth_secret_selection,
AuthSecretSelection::CreatingNew
));
assert_eq!(state.auth_secret_name(), None);
assert!(should_show_auth_secret_picker(&state));
assert!(!should_show_auth_secret_picker(&state));
}
#[test]
fn select_create_new_auth_secret_marks_creating_new_from_inherit() {
let mut state = remote_claude_state();
fn selecting_create_auth_secret_remains_a_distinct_state() {
let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Inherit;
state.select_create_new_auth_secret();
@@ -36,13 +36,13 @@ use crate::ai::blocklist::block::cli_controller::{
use crate::ai::blocklist::block::view_impl::output::action_icon;
use crate::ai::blocklist::block::view_impl::{
render_autonomy_checkbox_setting_speedbump_footer, render_citation, render_citation_chips,
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
use crate::ai::blocklist::block::{AIBlockAction, AutonomySettingSpeedbump};
use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
AIBlock, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel,
@@ -412,19 +412,23 @@ impl RequestedCommandView {
let is_finished = action_model
.as_ref(ctx)
.get_action_result(&action_id)
.get_action_result(client_ids.conversation_id, &action_id)
.is_some();
if !is_finished {
ctx.subscribe_to_model(action_model, |me, _, event, ctx| {
match event {
BlocklistAIActionEvent::QueuedAction(action_id)
BlocklistAIActionEvent::QueuedAction { action_id, .. }
if *action_id == me.action_id =>
{
ctx.notify();
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
if *action_id == me.action_id =>
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
if me.action_type.is_requested_command() {
me.ensure_editor(ctx);
@@ -432,8 +436,12 @@ impl RequestedCommandView {
me.set_is_header_expanded(true, ctx);
ctx.notify();
}
BlocklistAIActionEvent::ExecutingAction(action_id)
if *action_id == me.action_id =>
BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id
&& *action_id == me.action_id =>
{
// For shared-session viewers, sync the command text from the action when it starts executing.
if me.action_model.as_ref(ctx).is_view_only() {
@@ -467,11 +475,15 @@ impl RequestedCommandView {
}
ctx.notify();
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
..
} if *conversation_id == me.client_ids.conversation_id => {
let Some(action_result) = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(me.client_ids.conversation_id, action_id)
.cloned()
else {
log::info!("Got finished action event without result: {action_id}.");
@@ -724,7 +736,7 @@ impl RequestedCommandView {
fn is_waiting_for_user_confirmation(&self, app: &AppContext) -> bool {
self.action_model
.as_ref(app)
.get_action_status(&self.action_id)
.get_action_status(self.client_ids.conversation_id, &self.action_id)
.is_some_and(|status| status.is_blocked())
}
@@ -750,7 +762,9 @@ impl RequestedCommandView {
let Some(mouse_state_handle) =
self.citation_state_handles.get(copied_citation).cloned()
else {
log::warn!("Tried to retrieve mouse state handle for citation, but no mouse state handle exists.");
log::warn!(
"Tried to retrieve mouse state handle for citation, but no mouse state handle exists."
);
return None;
};
render_citation(
@@ -1108,7 +1122,7 @@ impl RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let mut title: Cow<'static, str>;
let mut font_override = None;
@@ -1457,7 +1471,7 @@ impl View for RequestedCommandView {
let action_status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.get_action_status(self.client_ids.conversation_id, &self.action_id);
let is_last_output_message_in_output = self
.block_model
@@ -1600,14 +1614,9 @@ impl View for RequestedCommandView {
content.add_child(Clipped::new(footer).finish());
}
let border_color = if action_status
let has_highlighted_border = action_status
.as_ref()
.is_some_and(|status| status.is_blocked())
{
theme.accent()
} else {
theme.surface_2()
};
.is_some_and(|status| status.is_blocked());
// If the requested command is expanded above a terminal block or
// the next exchange flows directly after, remove bottom margin for
@@ -1637,21 +1646,13 @@ impl View for RequestedCommandView {
}))
&& !is_input_pinned_to_top);
let container = Container::new(content.finish())
.with_margin_left(if action_status.is_some_and(|status| status.is_blocked()) {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
})
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(if should_remove_bottom_margin {
0.
} else {
CONTENT_ITEM_VERTICAL_MARGIN
})
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish();
let container = render_tool_pane_shell(
content.finish(),
has_highlighted_border,
self.is_header_expanded,
should_remove_bottom_margin,
app,
);
let mut root_stack = Stack::new();
root_stack.add_child(container);
@@ -7,14 +7,15 @@ use std::collections::HashMap;
use std::rc::Rc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult};
use ai::agent::action_result::{RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsResult};
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
use ai::skills::SkillReference;
use galaxy_core::send_telemetry_from_ctx;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Stack, Text, Wrap,
};
use warpui::keymap::FixedBinding;
use warpui::{
@@ -22,12 +23,16 @@ use warpui::{
ViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
use crate::ai::agent::{icons, AIAgentActionId, AIAgentActionResultType};
use crate::ai::blocklist::action_model::{
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor,
RunAgentsExecutorEvent, RunAgentsSpawningSnapshot,
};
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
dispatch_focus_or_open_child_agent_pane,
};
use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agent_pill;
use crate::ai::blocklist::block::model::AIBlockModel;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
@@ -50,6 +55,7 @@ use crate::ai::blocklist::telemetry::{
OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision,
RunAgentsCardDecisionEvent,
};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::connected_self_hosted_workers::{
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
};
@@ -139,7 +145,7 @@ impl RunAgentsEditState {
skills: self.skills.clone(),
model_id: self.orch.model_id.clone(),
harness_type: self.orch.harness_type.clone(),
execution_mode: self.orch.execution_mode.clone(),
execution_mode: RunAgentsExecutionMode::Local,
agent_run_configs: self.agent_run_configs.clone(),
plan_id: self.plan_id.clone(),
harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string),
@@ -213,11 +219,115 @@ pub enum RunAgentsCardViewEvent {
RejectRequested,
}
#[derive(Clone)]
struct RunAgentsChildState {
name: String,
conversation_id: Option<AIConversationId>,
removed: bool,
mouse_state: MouseStateHandle,
}
impl RunAgentsChildState {
fn new(name: String) -> Self {
Self {
name,
conversation_id: None,
removed: false,
mouse_state: MouseStateHandle::default(),
}
}
}
fn sync_run_agents_children(
children: &mut Vec<RunAgentsChildState>,
configs: &[RunAgentsAgentRunConfig],
) {
let mut previous_children = std::mem::take(children);
*children = configs
.iter()
.map(|config| {
previous_children
.iter()
.position(|child| child.name == config.name)
.map(|index| previous_children.remove(index))
.unwrap_or_else(|| RunAgentsChildState::new(config.name.clone()))
})
.collect();
}
fn link_run_agents_child(
children: &mut [RunAgentsChildState],
agent_name: &str,
conversation_id: AIConversationId,
) -> bool {
let child_index = children
.iter()
.position(|child| child.name == agent_name && child.conversation_id.is_none())
.or_else(|| children.iter().position(|child| child.name == agent_name));
let Some(child_index) = child_index else {
return false;
};
let child = &mut children[child_index];
child.conversation_id = Some(conversation_id);
child.removed = false;
true
}
fn has_run_agents_child(
children: &[RunAgentsChildState],
conversation_id: AIConversationId,
) -> bool {
children
.iter()
.any(|child| child.conversation_id == Some(conversation_id))
}
fn mark_run_agents_child_removed(
children: &mut [RunAgentsChildState],
conversation_id: AIConversationId,
) -> bool {
let Some(child) = children
.iter_mut()
.find(|child| child.conversation_id == Some(conversation_id))
else {
return false;
};
child.removed = true;
true
}
fn run_agents_event_matches_card(
event: &RunAgentsExecutorEvent,
conversation_id: Option<AIConversationId>,
action_id: &AIAgentActionId,
) -> bool {
let (event_conversation_id, event_action_id) = match event {
RunAgentsExecutorEvent::SpawningStarted {
conversation_id,
action_id,
..
}
| RunAgentsExecutorEvent::SpawningFinished {
conversation_id,
action_id,
} => (*conversation_id, action_id),
RunAgentsExecutorEvent::ChildConversationCreated {
action_id,
parent_conversation_id,
..
} => (*parent_conversation_id, action_id),
};
Some(event_conversation_id) == conversation_id && event_action_id == action_id
}
pub struct RunAgentsCardView {
action_id: AIAgentActionId,
state: RunAgentsEditState,
handles: RunAgentsCardHandles,
spawning: Option<RunAgentsSpawningSnapshot>,
children: Vec<RunAgentsChildState>,
terminal_view_id: warpui::EntityId,
/// Retained for interactive defaults and telemetry about plan-sourced
/// orchestration state.
active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>,
@@ -303,6 +413,12 @@ impl RunAgentsCardView {
ctx: &mut ViewContext<Self>,
) -> Self {
let state = RunAgentsEditState::from_request(request);
let children = state
.agent_run_configs
.iter()
.map(|config| RunAgentsChildState::new(config.name.clone()))
.collect();
let terminal_view_id = run_agents_executor.as_ref(ctx).terminal_view_id();
// Snapshot the raw incoming request so we can diff against the
// edited state at Accept time.
let original_tool_call_request = request.clone();
@@ -350,49 +466,86 @@ impl RunAgentsCardView {
});
let action_id_for_subscription = action_id.clone();
ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event {
RunAgentsExecutorEvent::SpawningStarted {
action_id,
snapshot,
} if action_id == &action_id_for_subscription => {
me.spawning = Some(*snapshot);
let conversation_id_for_subscription = block_model.conversation_id(ctx);
ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| {
if !run_agents_event_matches_card(
event,
conversation_id_for_subscription,
&action_id_for_subscription,
) {
return;
}
match event {
RunAgentsExecutorEvent::SpawningStarted { snapshot, .. } => {
me.spawning = Some(*snapshot);
ctx.notify();
}
RunAgentsExecutorEvent::SpawningFinished { .. } => {
me.spawning = None;
ctx.notify();
}
RunAgentsExecutorEvent::ChildConversationCreated {
agent_name,
child_conversation_id,
..
} => {
me.link_child_conversation(agent_name, *child_conversation_id);
ctx.notify();
}
}
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} if me.has_child_conversation(*conversation_id) => {
ctx.notify();
}
RunAgentsExecutorEvent::SpawningFinished { action_id }
if action_id == &action_id_for_subscription =>
{
me.spawning = None;
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} if me.mark_child_removed(*conversation_id) => {
ctx.notify();
}
RunAgentsExecutorEvent::SpawningStarted { .. }
| RunAgentsExecutorEvent::SpawningFinished { .. } => {}
_ => {}
});
// Re-render when this action finishes or becomes blocked.
let action_id_for_action_events = action_id.clone();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| {
if event.conversation_id().is_some_and(|conversation_id| {
me.block_model.conversation_id(ctx) != Some(conversation_id)
}) {
return;
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. }
if action_id == &action_id_for_action_events =>
{
ctx.notify();
}
ctx.notify();
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
// ready for user confirmation. Re-render so the card
// transitions from the "Configuring agents..." placeholder
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
me.emit_orchestration_entered_once(conversation_id, ctx);
}
ctx.notify();
}
_ => {}
}
_ => {}
});
// Repopulate the model picker when available Warp LLMs change.
@@ -481,6 +634,8 @@ impl RunAgentsCardView {
..Default::default()
},
spawning: None,
children,
terminal_view_id,
active_config,
is_accept_menu_open: false,
accept_menu,
@@ -543,6 +698,7 @@ impl RunAgentsCardView {
|| self.state.orch.model_id != new_state.orch.model_id
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
self.state = new_state;
self.sync_configured_children();
if harness_or_model_changed {
// Repopulate pickers and re-arm auto-open for the newly-
// streamed harness.
@@ -555,6 +711,26 @@ impl RunAgentsCardView {
}
}
fn sync_configured_children(&mut self) {
sync_run_agents_children(&mut self.children, &self.state.agent_run_configs);
}
fn link_child_conversation(&mut self, agent_name: &str, conversation_id: AIConversationId) {
if !link_run_agents_child(&mut self.children, agent_name, conversation_id) {
log::warn!(
"RunAgentsCardView: received child conversation for unknown agent '{agent_name}'"
);
}
}
fn has_child_conversation(&self, conversation_id: AIConversationId) -> bool {
has_run_agents_child(&self.children, conversation_id)
}
fn mark_child_removed(&mut self, conversation_id: AIConversationId) -> bool {
mark_run_agents_child_removed(&mut self.children, conversation_id)
}
/// Validates and dispatches the resolved request.
pub fn accept(&mut self, ctx: &mut ViewContext<Self>) {
self.handle_accept(ctx);
@@ -571,8 +747,11 @@ impl RunAgentsCardView {
let request = self.state.to_request();
self.emit_decision(RunAgentsCardDecision::Accept, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.execute_run_agents(&action_id, request, action_ctx);
action_model.execute_run_agents(conversation_id, &action_id, request, action_ctx);
});
}
@@ -664,10 +843,13 @@ impl RunAgentsCardView {
if self.block_model.is_restored() {
return;
}
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
if matches!(
self.action_model
.as_ref(ctx)
.get_action_status(&self.action_id),
.get_action_status(conversation_id, &self.action_id),
Some(AIActionStatus::Finished(_)) | Some(AIActionStatus::RunningAsync)
) {
return;
@@ -951,13 +1133,23 @@ impl View for RunAgentsCardView {
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let status = self
.action_model
.as_ref(app)
.get_action_status(&self.action_id);
.block_model
.conversation_id(app)
.and_then(|conversation_id| {
self.action_model
.as_ref(app)
.get_action_status(conversation_id, &self.action_id)
});
if let Some(AIActionStatus::Finished(result)) = &status {
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
return render_terminal_state(orchestrate_result, appearance, app);
return render_terminal_state(
orchestrate_result,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
log::error!(
"Unexpected action result type for orchestrate: {:?}",
@@ -969,13 +1161,25 @@ impl View for RunAgentsCardView {
// In-flight dispatch: check both spawning snapshot and action
// status because the event arrives one tick after the status.
if let Some(snapshot) = &self.spawning {
return render_spawning_card(snapshot, appearance, app);
return render_spawning_card(
snapshot,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
if matches!(status, Some(AIActionStatus::RunningAsync)) {
let snapshot = RunAgentsSpawningSnapshot {
agent_count: self.state.agent_run_configs.len(),
};
return render_spawning_card(&snapshot, appearance, app);
return render_spawning_card(
&snapshot,
&self.children,
self.terminal_view_id,
appearance,
app,
);
}
// Restored-from-history: dispatch state is lost, render as
@@ -1048,8 +1252,16 @@ impl TypedActionView for RunAgentsCardView {
RunAgentsCardViewAction::AcceptWithoutOrchestration => {
self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx);
let action_id = self.action_id.clone();
let Some(conversation_id) = self.block_model.conversation_id(ctx) else {
return;
};
self.action_model.update(ctx, |action_model, action_ctx| {
action_model.deny_run_agents(&action_id, String::new(), action_ctx);
action_model.deny_run_agents(
conversation_id,
&action_id,
String::new(),
action_ctx,
);
});
}
RunAgentsCardViewAction::ToggleAcceptMenu => {
@@ -1352,11 +1564,21 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box<dy
fn render_terminal_state(
result: &RunAgentsResult,
children: &[RunAgentsChildState],
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let (label, kind) = format_terminal_state(result);
render_status_only_card(label, appearance, kind, app)
render_status_card(
label,
appearance,
kind,
children,
Some(result),
Some(terminal_view_id),
app,
)
}
pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) {
@@ -1367,14 +1589,31 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
.count();
if launched == total {
let completed = agents
.iter()
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Completed { .. }))
.count();
let successful = launched + completed;
if completed > 0 && completed == total {
let label = if total == 1 {
"Completed 1 agent".to_string()
} else {
format!("Completed {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 && completed > 0 {
(
format!("Completed {completed} of {total} agents"),
StatusKind::Mixed,
)
} else if successful == total {
let label = if total == 1 {
"Spawned 1 agent".to_string()
} else {
format!("Spawned {total} agents")
};
(label, StatusKind::Success)
} else if launched == 0 {
} else if successful == 0 {
// Every child failed to launch: surface a terminal failure
// rather than the in-progress-looking mixed state.
let label = if total == 1 {
@@ -1385,7 +1624,7 @@ pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, Status
(label, StatusKind::Failure)
} else {
(
format!("Spawned {launched} of {total} agents"),
format!("Spawned {successful} of {total} agents"),
StatusKind::Mixed,
)
}
@@ -1424,6 +1663,8 @@ pub(crate) enum StatusKind {
fn render_spawning_card(
snapshot: &RunAgentsSpawningSnapshot,
children: &[RunAgentsChildState],
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
@@ -1433,7 +1674,15 @@ fn render_spawning_card(
} else {
format!("Spawning {total} agents\u{2026}")
};
render_status_only_card(label, appearance, StatusKind::Spawning, app)
render_status_card(
label,
appearance,
StatusKind::Spawning,
children,
None,
Some(terminal_view_id),
app,
)
}
fn render_status_only_card(
@@ -1441,6 +1690,19 @@ fn render_status_only_card(
appearance: &Appearance,
kind: StatusKind,
app: &AppContext,
) -> Box<dyn Element> {
render_status_card(label, appearance, kind, &[], None, None, app)
}
#[allow(clippy::too_many_arguments)]
fn render_status_card(
label: String,
appearance: &Appearance,
kind: StatusKind,
children: &[RunAgentsChildState],
result: Option<&RunAgentsResult>,
terminal_view_id: Option<warpui::EntityId>,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let icon = match kind {
@@ -1452,7 +1714,8 @@ fn render_status_only_card(
StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(),
StatusKind::Cancelled => inline_action_icons::cancelled_icon(appearance).finish(),
};
let row = render_requested_action_row_for_text(
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_requested_action_row_for_text(
label.into(),
appearance.ui_font_family(),
Some(icon),
@@ -1460,8 +1723,49 @@ fn render_status_only_card(
false,
false,
app,
);
Container::new(row)
));
if !children.is_empty() {
let Some(terminal_view_id) = terminal_view_id else {
log::error!("RunAgentsCardView: child rows require a terminal view id");
return Empty::new().finish();
};
let outcomes = match result {
Some(RunAgentsResult::Launched { agents, .. }) => Some(agents.as_slice()),
Some(
RunAgentsResult::Denied { .. }
| RunAgentsResult::Failure { .. }
| RunAgentsResult::Cancelled,
)
| None => None,
};
let mut child_column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, child) in children.iter().enumerate() {
let outcome = outcomes.and_then(|agents| agents.get(index));
child_column.add_child(
Container::new(render_run_agents_child_row(
child,
outcome,
result.is_some(),
terminal_view_id,
appearance,
app,
))
.with_margin_top(4.)
.finish(),
);
}
column.add_child(
Container::new(child_column.finish())
.with_padding_left(8.)
.with_padding_right(8.)
.with_padding_bottom(8.)
.finish(),
);
}
Container::new(column.finish())
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
@@ -1469,6 +1773,93 @@ fn render_status_only_card(
.finish()
}
fn render_run_agents_child_row(
child: &RunAgentsChildState,
outcome: Option<&RunAgentsAgentOutcome>,
is_terminal: bool,
terminal_view_id: warpui::EntityId,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id }
| RunAgentsAgentOutcomeKind::Completed { agent_id, .. } => {
conversation_id_for_agent_id(agent_id, app)
}
RunAgentsAgentOutcomeKind::Failed { .. } => None,
});
let conversation_id = child.conversation_id.or(outcome_conversation_id);
if !child.removed {
if let Some(conversation_id) = conversation_id {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
{
let status = conversation.status();
let status_icon =
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
let mouse_state = child.mouse_state.clone();
return conversation_navigation_card_with_icon(
Some(status_icon),
child.name.clone(),
Some(status.to_string()),
move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
conversation_id,
terminal_view_id,
ctx,
app,
);
},
mouse_state,
true,
None,
app,
);
}
}
}
let (status, label) = if child.removed {
(ConversationStatus::Cancelled, "Removed".to_string())
} else if let Some(outcome) = outcome {
match &outcome.kind {
RunAgentsAgentOutcomeKind::Launched { .. } => {
(ConversationStatus::Success, "Started".to_string())
}
RunAgentsAgentOutcomeKind::Completed { .. } => {
(ConversationStatus::Success, "Completed".to_string())
}
RunAgentsAgentOutcomeKind::Failed { error } => (
ConversationStatus::Error,
if error.trim().is_empty() {
"Failed".to_string()
} else {
format!("Failed: {error}")
},
),
}
} else if is_terminal {
(ConversationStatus::Cancelled, "Not started".to_string())
} else {
(
ConversationStatus::InProgress,
"Starting\u{2026}".to_string(),
)
};
let (icon, color) =
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
render_requested_action_row_for_text(
format!("{}: {label}", child.name).into(),
appearance.ui_font_family(),
Some(icon.to_warpui_icon(color.into()).finish()),
None,
false,
false,
app,
)
}
fn render_editor(
state: &RunAgentsEditState,
handles: &RunAgentsCardHandles,
@@ -1490,7 +1881,6 @@ fn render_editor(
column.add_child(
Container::new(oc::render_mode_toggle(
state.orch.execution_mode.is_remote(),
&handles.pickers,
appearance,
None,
@@ -8,7 +8,13 @@ use ai::agent::action_result::{
use ai::skills::SkillReference;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::RunAgentsEditState;
use super::{
has_run_agents_child, link_run_agents_child, mark_run_agents_child_removed,
run_agents_event_matches_card, sync_run_agents_children, RunAgentsChildState,
RunAgentsEditState, RunAgentsExecutorEvent,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentActionId;
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest {
@@ -57,30 +63,20 @@ fn make_edit_state_with_orch_fields(
}
#[test]
fn local_to_cloud_initializes_remote_with_empty_environment() {
fn remote_toggle_remains_local() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
computer_use_enabled,
} = state.orch.execution_mode
else {
panic!("expected Remote after toggle");
};
assert_eq!(environment_id, "");
assert_eq!(worker_host, "warp");
assert!(!computer_use_enabled);
}
#[test]
fn cloud_to_local_drops_environment() {
fn legacy_remote_request_normalizes_to_local() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -97,15 +93,21 @@ fn cloud_to_local_drops_environment() {
}
#[test]
fn local_to_cloud_resets_opencode_to_oz() {
fn remote_toggle_preserves_supported_local_harness() {
let mut state =
RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.harness_type, "opencode");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn cloud_without_env_no_longer_disables_accept() {
fn legacy_remote_request_without_environment_allows_local_acceptance() {
let state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -114,15 +116,15 @@ fn cloud_without_env_no_longer_disables_accept() {
computer_use_enabled: false,
},
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud without env should NOT disable Accept (soft recommendation only)"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(state.orch.accept_disabled_reason().is_none());
}
#[test]
fn cloud_with_opencode_disables_accept() {
// Bypass the toggle helper to test the validation gate directly.
fn legacy_remote_opencode_request_is_normalized_and_allowed_locally() {
let state = RunAgentsEditState::from_request(&make_request(
"opencode",
RunAgentsExecutionMode::Remote {
@@ -131,9 +133,12 @@ fn cloud_with_opencode_disables_accept() {
computer_use_enabled: false,
},
));
let reason = state.orch.accept_disabled_reason();
assert!(reason.is_some(), "Cloud + OpenCode should disable Accept");
assert!(reason.unwrap().contains("OpenCode"));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(state.orch.accept_disabled_reason(), None);
}
#[test]
@@ -149,12 +154,11 @@ fn local_with_any_harness_does_not_disable_accept() {
}
#[test]
fn local_with_disabled_codex_disables_accept() {
fn local_with_disabled_codex_is_sanitized() {
let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local);
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.accept_disabled_reason(), None);
}
#[test]
@@ -168,7 +172,7 @@ fn from_request_sanitizes_disabled_local_harness_to_oz() {
}
#[test]
fn cloud_with_env_and_non_opencode_harness_allows_accept() {
fn legacy_remote_harnesses_normalize_and_allow_local_acceptance() {
for harness in ["oz", "claude", "gemini"] {
let state = RunAgentsEditState::from_request(&make_request(
harness,
@@ -178,9 +182,13 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() {
computer_use_enabled: false,
},
));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert!(
state.orch.accept_disabled_reason().is_none(),
"Cloud + env + {harness} should allow Accept"
"normalized local + {harness} should allow Accept"
);
}
}
@@ -197,7 +205,7 @@ fn set_environment_id_no_op_in_local_mode() {
}
#[test]
fn set_environment_id_updates_remote() {
fn set_environment_id_is_ignored_for_normalized_remote_request() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -206,15 +214,17 @@ fn set_environment_id_updates_remote() {
computer_use_enabled: false,
},
));
state.orch.set_environment_id("new-env".to_string());
let RunAgentsExecutionMode::Remote { environment_id, .. } = state.orch.execution_mode else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn to_request_round_trips_request_fields() {
fn to_request_preserves_fields_but_normalizes_execution_to_local() {
let mut req = make_request_with_skills(
"claude",
RunAgentsExecutionMode::Remote {
@@ -232,16 +242,96 @@ fn to_request_round_trips_request_fields() {
req.plan_id = "plan-1".to_string();
let state = RunAgentsEditState::from_request(&req);
let round_tripped = state.to_request();
assert_eq!(round_tripped.summary, req.summary);
assert_eq!(round_tripped.base_prompt, req.base_prompt);
assert_eq!(round_tripped.model_id, req.model_id);
assert_eq!(round_tripped.harness_type, req.harness_type);
assert_eq!(round_tripped.execution_mode, req.execution_mode);
assert!(matches!(
round_tripped.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(round_tripped.agent_run_configs, req.agent_run_configs);
assert_eq!(round_tripped.skills, req.skills);
assert_eq!(round_tripped.plan_id, req.plan_id);
}
#[test]
fn live_child_links_and_removal_survive_streaming_config_sync() {
let first_id = AIConversationId::new();
let replacement_id = AIConversationId::new();
let mut children = vec![
RunAgentsChildState::new("alpha".to_string()),
RunAgentsChildState::new("beta".to_string()),
];
assert!(link_run_agents_child(&mut children, "alpha", first_id));
assert!(has_run_agents_child(&children, first_id));
assert!(mark_run_agents_child_removed(&mut children, first_id));
assert!(children[0].removed);
let configs = vec![
RunAgentsAgentRunConfig {
name: "gamma".to_string(),
prompt: "new work".to_string(),
title: String::new(),
},
RunAgentsAgentRunConfig {
name: "alpha".to_string(),
prompt: "updated work".to_string(),
title: String::new(),
},
];
sync_run_agents_children(&mut children, &configs);
assert_eq!(
children
.iter()
.map(|child| child.name.as_str())
.collect::<Vec<_>>(),
vec!["gamma", "alpha"]
);
assert_eq!(children[1].conversation_id, Some(first_id));
assert!(children[1].removed);
assert!(link_run_agents_child(
&mut children,
"alpha",
replacement_id
));
assert_eq!(children[1].conversation_id, Some(replacement_id));
assert!(!children[1].removed);
assert!(!link_run_agents_child(
&mut children,
"missing",
AIConversationId::new()
));
}
#[test]
fn child_created_with_duplicate_action_id_only_matches_parent_conversation() {
let card_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let child_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_string());
let event = RunAgentsExecutorEvent::ChildConversationCreated {
action_id: duplicate_action_id.clone(),
agent_name: "child".to_string(),
parent_conversation_id: other_conversation_id,
child_conversation_id,
};
assert!(!run_agents_event_matches_card(
&event,
Some(card_conversation_id),
&duplicate_action_id,
));
assert!(run_agents_event_matches_card(
&event,
Some(other_conversation_id),
&duplicate_action_id,
));
}
mod format_terminal_state_tests {
use super::super::{format_terminal_state, StatusKind};
use super::*;
@@ -264,6 +354,16 @@ mod format_terminal_state_tests {
}
}
fn completed(name: &str, agent_id: &str) -> RunAgentsAgentOutcome {
RunAgentsAgentOutcome {
name: name.to_string(),
kind: RunAgentsAgentOutcomeKind::Completed {
agent_id: agent_id.to_string(),
output: format!("{name} output"),
},
}
}
fn launched_result(agents: Vec<RunAgentsAgentOutcome>) -> RunAgentsResult {
RunAgentsResult::Launched {
model_id: "auto".to_string(),
@@ -305,6 +405,30 @@ mod format_terminal_state_tests {
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_completed_uses_completed_label_and_success_status() {
let result = launched_result(vec![
completed("a", "a-1"),
completed("b", "a-2"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 3 agents");
assert!(matches!(kind, StatusKind::Success));
}
#[test]
fn mixed_completed_and_failed_uses_completed_label_and_mixed_status() {
let result = launched_result(vec![
completed("a", "a-1"),
failed("b", "boom"),
completed("c", "a-3"),
]);
let (label, kind) = format_terminal_state(&result);
assert_eq!(label, "Completed 2 of 3 agents");
assert!(matches!(kind, StatusKind::Mixed));
}
#[test]
fn all_failed_uses_failure_status_not_mixed() {
let result = launched_result(vec![
@@ -414,34 +538,27 @@ mod override_from_approved_config_tests {
#[test]
fn overrides_even_when_request_has_values() {
let mut state = RunAgentsEditState::from_request(&make_request(
"claude",
RunAgentsExecutionMode::Local,
));
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&local_config("gpt-5", "codex"));
assert_eq!(state.orch.model_id, "gpt-5");
assert_eq!(state.orch.harness_type, "codex");
.override_from_approved_config(&local_config("sonnet", "claude"));
assert_eq!(state.orch.model_id, "sonnet");
assert_eq!(state.orch.harness_type, "claude");
}
#[test]
fn overrides_local_to_remote() {
fn remote_config_override_stays_local() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
environment_id,
worker_host,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote after override");
};
assert_eq!(environment_id, "env-1");
assert_eq!(worker_host, "warp");
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
@@ -464,7 +581,7 @@ mod override_from_approved_config_tests {
}
#[test]
fn preserves_computer_use_when_both_remote() {
fn remote_request_and_remote_override_drop_computer_use() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -476,57 +593,29 @@ mod override_from_approved_config_tests {
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "new-env"));
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(environment_id, "new-env", "env should come from config");
assert!(
*computer_use_enabled,
"computer_use_enabled should be preserved from original request"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
#[test]
fn does_not_carry_computer_use_from_local_to_remote() {
fn approved_local_disabled_harness_is_sanitized() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
computer_use_enabled,
..
} = &state.orch.execution_mode
else {
panic!("expected Remote");
};
assert!(
!*computer_use_enabled,
"computer_use_enabled should default to false when original was Local"
);
}
.override_from_approved_config(&local_config("gpt-5", "codex"));
#[test]
fn approved_local_disabled_harness_reports_disabled_reason_after_override() {
let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state
.orch
.override_from_approved_config(&local_config("auto", "codex"));
assert_eq!(
state.orch.accept_disabled_reason(),
Some("Local Codex child agents are temporarily disabled.")
);
assert_eq!(state.orch.harness_type, "oz");
assert_eq!(state.orch.model_id, "");
assert_eq!(state.orch.accept_disabled_reason(), None);
}
}
#[test]
fn local_to_cloud_idempotent_when_already_remote() {
fn remote_toggle_is_idempotently_local() {
let mut state = RunAgentsEditState::from_request(&make_request(
"oz",
RunAgentsExecutionMode::Remote {
@@ -535,21 +624,11 @@ fn local_to_cloud_idempotent_when_already_remote() {
computer_use_enabled: true,
},
));
state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id,
computer_use_enabled,
..
} = state.orch.execution_mode
else {
panic!("expected Remote");
};
assert_eq!(
environment_id, "env-1",
"toggle to Remote when already Remote should not clobber env"
);
assert!(
computer_use_enabled,
"toggle to Remote when already Remote should not clobber computer_use"
);
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
}
@@ -0,0 +1,43 @@
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::{Border, Container, CornerRadius, ParentElement, Radius};
use warpui::{AppContext, Element, SingletonEntity};
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::block::view_impl::{
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
};
/// Renders the shared outer shell used by native and runtime-owned tool panes.
///
/// Callers own execution and body content. This function owns the pane geometry
/// and theme treatment so display-only runtimes cannot drift from native tools.
pub(crate) fn render_tool_pane_shell(
content: Box<dyn Element>,
has_highlighted_border: bool,
spans_conversation_width: bool,
should_remove_bottom_margin: bool,
app: &AppContext,
) -> Box<dyn Element> {
let theme = Appearance::as_ref(app).theme();
let border_color = if has_highlighted_border {
theme.accent()
} else {
theme.surface_2()
};
Container::new(content)
.with_margin_left(if has_highlighted_border || spans_conversation_width {
CONTENT_HORIZONTAL_PADDING
} else {
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
})
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(if should_remove_bottom_margin {
0.
} else {
CONTENT_ITEM_VERTICAL_MARGIN
})
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
@@ -469,6 +469,7 @@ impl OrchestrationEventService {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)
@@ -11,7 +11,6 @@ use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent};
use crate::ai::agent::api::generate_multi_agent_output;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIIdentifiers, FileContext, PassiveCodeDiffEntry, PassiveSuggestionTrigger,
@@ -486,7 +485,7 @@ async fn extract_suggestion_from_stream(
let mut client_actions: Vec<api::ClientAction> = Vec::new();
let mut server_request_token: Option<String> = None;
while let Some(event) = stream.next().await {
let Ok(response_event) = event else {
let Ok(crate::ai::agent::api::StreamEvent::Response(response_event)) = event else {
continue;
};
match response_event.r#type {
@@ -74,6 +74,9 @@ fn initialize_permissions_test_with_mode(
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
let profile_model = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
+2 -1
View File
@@ -77,7 +77,8 @@ impl TryFrom<&AIAgentInput> for PersistedAIInputType {
AIAgentInput::PassiveSuggestionResult { suggestion: PassiveSuggestionResultType::CodeDiff { .. }, .. } => Err(anyhow!(
"PassiveSuggestionResult::CodeDiff is not persisted as a query."
)),
AIAgentInput::ActionResult { .. }
AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::ActionResult { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
| AIAgentInput::CreateEnvironment { .. }
+27 -120
View File
@@ -15,26 +15,19 @@ use warpui::{
};
use crate::ai::agent::SuggestedRule;
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::Owner;
use crate::drive::CloudObjectTypeAndId;
use crate::ai::facts::{AIFact, AIMemory};
use crate::cloud_object::CloudObject;
use crate::editor::{
EditorOptions, EditorView, EnterAction, EnterSettings, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::local_object_repository::{LocalObjectRepository, LocalObjectRepositoryEvent};
use crate::modal::{Modal, ModalEvent};
use crate::network::NetworkStatus;
use crate::send_telemetry_from_ctx;
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::SyncId;
use crate::server::telemetry::TelemetryEvent;
use crate::ui_components::blended_colors;
use crate::view_components::action_button::{ActionButton, PrimaryTheme};
use crate::workspaces::user_workspaces::UserWorkspaces;
const HEADER_TEXT: &str = "Suggested rule";
const MAX_EDITOR_HEIGHT: f32 = 240.;
@@ -218,7 +211,6 @@ pub struct SuggestedRuleAndId {
struct SuggestedRuleView {
rule_and_id: Option<SuggestedRuleAndId>,
owner: Option<Owner>,
is_saved: bool,
current_editor: EditorType,
name_editor: ViewHandle<EditorView>,
@@ -230,31 +222,11 @@ struct SuggestedRuleView {
impl SuggestedRuleView {
fn new(ctx: &mut ViewContext<Self>) -> Self {
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
me.handle_cloud_model_event(event, ctx);
});
let owner = UserWorkspaces::as_ref(ctx).personal_drive(ctx);
let network_status = NetworkStatus::handle(ctx);
ctx.subscribe_to_model(&network_status, |me, _, _event, ctx| {
let is_edit_allowed = me.is_edit_allowed(ctx);
let tooltip = if !is_edit_allowed {
Some("Editing is disabled while offline.".to_string())
} else {
None
};
me.edit_button.update(ctx, |edit_button, ctx| {
edit_button.set_disabled(!is_edit_allowed, ctx);
edit_button.set_tooltip(tooltip, ctx);
});
ctx.notify();
let local_objects = LocalObjectRepository::handle(ctx);
ctx.subscribe_to_model(&local_objects, |me, _, event, ctx| {
if matches!(event, LocalObjectRepositoryEvent::Rules) {
me.handle_rules_changed(ctx);
}
});
let appearance = Appearance::as_ref(ctx);
@@ -319,7 +291,6 @@ impl SuggestedRuleView {
Self {
rule_and_id: None,
owner,
is_saved: false,
current_editor: EditorType::Name,
name_editor,
@@ -341,15 +312,6 @@ impl SuggestedRuleView {
ctx.notify();
}
pub fn is_edit_allowed(&self, ctx: &mut ViewContext<Self>) -> bool {
let Some(SuggestedRuleAndId { sync_id, .. }) = &self.rule_and_id else {
return false;
};
let is_online = NetworkStatus::as_ref(ctx).is_online();
is_online || sync_id.into_server().is_none()
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
let (current_editor, next_editor, next_editor_type) = match self.current_editor {
EditorType::Name => (&self.name_editor, &self.content_editor, EditorType::Content),
@@ -398,62 +360,17 @@ impl SuggestedRuleView {
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ViewContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
fn handle_rules_changed(&mut self, ctx: &mut ViewContext<Self>) {
let Some(rule_and_id) = &self.rule_and_id else {
return;
};
if let (ObjectOperation::Create { .. }, OperationSuccessType::Success) =
(&result.operation, &result.success_type)
if LocalObjectRepository::as_ref(ctx)
.rule(&rule_and_id.sync_id, ctx)
.is_some()
{
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id.into_client() == result.client_id {
if let Some(server_id) = result.server_id {
self.rule_and_id = Some(SuggestedRuleAndId {
rule: rule_and_id.rule.clone(),
sync_id: SyncId::ServerId(server_id),
});
// Reload the rule from the cloud model.
self.load_rule(ctx);
}
}
}
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext<Self>) {
match event {
CloudModelEvent::ObjectUpdated {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
} => {
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id.into_client() == id.into_client() {
self.load_rule(ctx);
}
}
}
CloudModelEvent::ObjectTrashed {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
}
| CloudModelEvent::ObjectDeleted {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
} => {
// If the rule has been deleted, then we should reset the rule such that
// the suggestion can be added again.
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id == *id {
self.reset_rule(ctx);
}
}
}
_ => {}
self.load_rule(ctx);
} else if self.is_saved {
self.reset_rule(ctx);
}
}
@@ -481,17 +398,13 @@ impl SuggestedRuleView {
ctx.notify();
}
/// Fetches the rule from the cloud model, and updates the UI to reflect that.
/// Fetches the rule from the local repository, and updates the UI to reflect that.
fn load_rule(&mut self, ctx: &mut ViewContext<Self>) {
let Some(SuggestedRuleAndId { sync_id, .. }) = &self.rule_and_id else {
return;
};
let cloud_model = CloudModel::handle(ctx);
if let Some(rule) = cloud_model
.as_ref(ctx)
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(sync_id)
{
if let Some(rule) = LocalObjectRepository::as_ref(ctx).rule(sync_id, ctx) {
let AIFact::Memory(AIMemory { name, content, .. }) = rule.model().string_model.clone();
self.name_editor.update(ctx, |name_editor, ctx| {
name_editor.set_buffer_text(&name.unwrap_or("Untitled".to_string()), ctx);
@@ -509,27 +422,21 @@ impl SuggestedRuleView {
return;
};
// Add rule as a WD object.
let update_manager = UpdateManager::handle(ctx);
let name = if self.name_editor.as_ref(ctx).buffer_text(ctx).is_empty() {
None
} else {
Some(self.name_editor.as_ref(ctx).buffer_text(ctx).clone())
};
let content = self.content_editor.as_ref(ctx).buffer_text(ctx);
if let Some(owner) = self.owner {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name,
content,
suggested_logging_id: Some(rule.logging_id.clone()),
});
update_manager.update(ctx, |update_manager, ctx| {
if let Some(client_id) = sync_id.into_client() {
update_manager.create_ai_fact(ai_fact, client_id, owner, ctx);
}
});
}
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name,
content,
suggested_logging_id: Some(rule.logging_id.clone()),
});
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_rule_with_id(sync_id, ai_fact, ctx);
});
self.on_add_rule(ctx);
ctx.emit(SuggestedRuleDialogEvent::AddNewRule { rule });
}
@@ -45,6 +45,7 @@ impl View for ContextWindowView {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.len(),
ContentPart::Reasoning { text, .. } => text.len(),
ContentPart::Image { .. } => 6_400,
ContentPart::ToolUse { input, .. } => input.to_string().len(),
ContentPart::ToolResult { content, .. } => content.len(),
@@ -112,6 +113,14 @@ impl View for ContextWindowView {
ContentPart::Text(t) => {
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
}
ContentPart::Reasoning { text, signature } => {
out.push_str(&format!(
"[Part {} Reasoning] signed={}\n{}\n",
pi,
signature.is_some(),
text
));
}
ContentPart::Image { data, mime_type } => {
out.push_str(&format!(
"[Part {} Image] mime_type={}, bytes={}\n",