first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+139 -47
View File
@@ -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(
&current_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,
@@ -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,