Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy
This commit is contained in:
+1220
-152
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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| {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(_)
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+4618
-441
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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user