Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
use crate::ai::blocklist::BlocklistAIPermissions;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub enum AskUserQuestionDecision {
Completed(Vec<AskUserQuestionAnswerItem>),
Cancelled,
}
pub struct AskUserQuestionExecutor {
result_rx: (
async_channel::Sender<AskUserQuestionDecision>,
async_channel::Receiver<AskUserQuestionDecision>,
),
terminal_view_id: EntityId,
}
impl AskUserQuestionExecutor {
pub fn new(terminal_view_id: EntityId) -> Self {
Self {
result_rx: async_channel::unbounded(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
!BlocklistAIPermissions::as_ref(ctx).can_ask_user_question(
&input.conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let questions = match &input.action.action {
AIAgentActionType::AskUserQuestion { questions } => questions,
_ => {
return ActionExecution::InvalidAction;
}
};
if self.should_autoexecute(input, ctx) {
let question_ids = questions
.iter()
.map(|question| question.question_id.clone())
.collect();
return ActionExecution::Sync(AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::SkippedByAutoApprove { question_ids },
));
}
let receiver = self.result_rx.1.clone();
ActionExecution::new_async(
async move { receiver.recv().await },
|result, _ctx| match result {
Ok(AskUserQuestionDecision::Completed(answers)) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success {
answers,
})
}
Ok(AskUserQuestionDecision::Cancelled) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
Err(_) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete(&self, answers: Vec<AskUserQuestionAnswerItem>) {
let _ = self
.result_rx
.0
.try_send(AskUserQuestionDecision::Completed(answers));
}
pub fn cancel(&self) {
let _ = self
.result_rx
.0
.try_send(AskUserQuestionDecision::Cancelled);
}
}
#[cfg(test)]
impl Default for AskUserQuestionExecutor {
fn default() -> Self {
Self::new(EntityId::new())
}
}
impl Entity for AskUserQuestionExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "ask_user_question_tests.rs"]
mod tests;
@@ -0,0 +1,285 @@
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::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::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
use crate::LaunchMode;
use ai::agent::action::AskUserQuestionItem;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use warpui::{App, EntityId, ModelHandle};
fn build_action(action_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(action_id.to_string()),
action: AIAgentActionType::AskUserQuestion {
questions: vec![AskUserQuestionItem {
question_id: "q1".to_string(),
question: "What should we use?".to_string(),
question_type: ai::agent::action::AskUserQuestionType::MultipleChoice {
is_multiselect: false,
options: vec![],
supports_other: true,
},
}],
},
task_id: TaskId::new(format!("task-{action_id}")),
requires_result: false,
}
}
#[test]
fn should_autoexecute_returns_false_when_autoapprove_is_enabled_and_profile_always_blocks() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, profiles) = initialize_ask_user_question_test(&mut app, terminal_view_id);
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)
});
profiles.update(&mut app, |profiles, ctx| {
let profile_id = *profiles.active_profile(Some(terminal_view_id), ctx).id();
profiles.set_ask_user_question(profile_id, AskUserQuestionPermission::AlwaysAsk, ctx);
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(!result);
});
}
fn initialize_ask_user_question_test(
app: &mut App,
terminal_view_id: EntityId,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<AIExecutionProfilesModel>,
) {
initialize_settings_for_tests(app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
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(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
app.add_singleton_model(BlocklistAIPermissions::new);
// Ensure asking questions is allowed by default regardless of compile-time profile
// defaults (e.g. agent_mode_evals overrides ask_user_question to Never).
profiles.update(app, |profiles, ctx| {
if let Some(profile_id) = profiles.create_profile(ctx) {
profiles.set_ask_user_question(
profile_id,
AskUserQuestionPermission::AskExceptInAutoApprove,
ctx,
);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
}
});
(history, profiles)
}
#[test]
fn should_autoexecute_returns_false_when_questions_are_allowed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor.should_autoexecute(input, ctx)
});
assert!(!result);
});
}
#[test]
fn should_autoexecute_returns_true_when_autoapprove_is_enabled_and_profile_allows_override() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, _) = initialize_ask_user_question_test(&mut app, terminal_view_id);
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)
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(result);
});
}
#[test]
fn execute_returns_sync_skipped_question_ids_when_autoapprove_is_enabled() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, _) = initialize_ask_user_question_test(&mut app, terminal_view_id);
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)
});
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::Sync(AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::SkippedByAutoApprove { question_ids }
)) if question_ids == vec!["q1".to_string()]
));
});
}
#[test]
fn execute_returns_async_and_resolves_on_complete() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("action-a");
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
executor.update(&mut app, |executor, _| {
executor.complete(vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}]);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success { answers })
if answers
== vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}]
));
});
}
#[test]
fn cancel_resolves_as_cancelled() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
executor.update(&mut app, |executor, _| {
executor.cancel();
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
));
});
}
#[test]
fn should_autoexecute_uses_active_terminal_profile_permission() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, profiles) = initialize_ask_user_question_test(&mut app, terminal_view_id);
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)
});
profiles.update(&mut app, |profiles, ctx| {
let profile_id = profiles
.create_profile(ctx)
.expect("test profile should be created");
profiles.set_ask_user_question(profile_id, AskUserQuestionPermission::Never, ctx);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(result);
});
}
@@ -0,0 +1,281 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::terminal::model::session::active_session::ActiveSession;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use super::get_server_output_id;
#[cfg(not(target_family = "wasm"))]
use crate::{
ai::{
agent::{AIAgentAction, AIAgentActionResultType, CallMCPToolResult},
blocklist::{action_model::AIAgentActionType, BlocklistAIPermissions},
mcp::TemplatableMCPServerManager,
},
send_telemetry_from_app_ctx, TelemetryEvent,
};
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
pub struct CallMCPToolExecutor {
_active_session: ModelHandle<ActiveSession>,
#[allow(dead_code)]
terminal_view_id: EntityId,
}
impl CallMCPToolExecutor {
pub fn new(_active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
_active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::CallMCPTool {
server_id, name, ..
},
..
},
conversation_id,
} = input
else {
return false;
};
BlocklistAIPermissions::as_ref(ctx).can_call_mcp_tool(
server_id.as_ref(),
name.as_str(),
&conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction
}
#[cfg(not(target_family = "wasm"))]
{
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
AIAgentActionType::CallMCPTool {
server_id,
name,
input,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
let serde_json::Value::Object(mut arguments) = input.clone() else {
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
};
// Prefer the templatable server over the legacy server if both exist.
// It is possible for both to exist in some tricky race conditions, but in those cases
// we shouldn't care about the legacy servers.
let templatable_mcp_manager = TemplatableMCPServerManager::as_ref(ctx);
// Coerce whole-number f64 args to i64 for fields declared as `"type": "integer"`
// in the tool's input schema. MCP tool args round-trip through
// `google.protobuf.Struct` on the wire, which erases the integer/float distinction
// by storing everything as f64. Without coercion, the ryu formatter serializes
// whole-number f64 as "5.0", which strict MCP servers (e.g. GoLand) reject for
// integer-typed fields.
if let Some(schema) =
templatable_mcp_manager.tool_input_schema(*server_id, name.as_str())
{
coerce_integer_args(&mut arguments, &schema);
}
let templatable_peer = if let Some(installation_id) = server_id {
templatable_mcp_manager
.server_with_installation_id_and_tool_name(*installation_id, name.to_owned())
} else {
templatable_mcp_manager.server_with_tool_name(name.to_owned())
};
let Some(reconnecting_peer) = templatable_peer else {
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
let name_owned_inner = name_owned.clone();
ActionExecution::new_async(
async move {
reconnecting_peer
.call_tool(rmcp::model::CallToolRequestParam {
name: name_owned_inner.into(),
arguments: Some(arguments),
})
.await
},
move |res, ctx| handle_call_tool_result(res, server_output_id, name_clone, ctx),
)
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for CallMCPToolExecutor {
type Event = ();
}
/// Coerces whole-number floats in `args` to integers for fields declared as
/// [`"type": "integer"`](https://json-schema.org/understanding-json-schema/reference/type)
/// in the tool's JSON Schema `input_schema`.
///
/// 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.
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;
};
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;
}
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;
}
if let Ok(i) = i64::try_from(f as i128) {
*n = serde_json::Number::from(i);
}
}
}
#[cfg(test)]
#[path = "call_mcp_tool_tests.rs"]
mod tests;
/// Handles the result of a call_tool request, converting it to an AIAgentActionResultType.
#[cfg(not(target_family = "wasm"))]
fn handle_call_tool_result(
res: Result<rmcp::model::CallToolResult, rmcp::ServiceError>,
server_output_id: Option<crate::ai::blocklist::action_model::execute::ServerOutputId>,
tool_name: String,
ctx: &warpui::AppContext,
) -> AIAgentActionResultType {
let action_result = match res {
Ok(result) => {
// Even if the call was successful, the response could still be an error so we need to check.
if matches!(result.is_error, Some(true)) {
let error_message = result
.structured_content
.map(|content| content.to_string())
.unwrap_or_else(|| {
let content_str = result
.content
.into_iter()
.filter_map(|content| {
use rmcp::model::RawContent::*;
if let Text(raw_text_content) = content.raw {
Some(raw_text_content.text)
} else {
log::warn!("Error content found unsupported content type");
None
}
})
.collect_vec()
.join("\n");
if content_str.is_empty() {
"MCP tool call returned an error.".to_string()
} else {
content_str
}
});
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: Some(
crate::server::telemetry::MCPServerTelemetryError::ResponseError(
error_message.clone()
)
),
},
ctx
);
CallMCPToolResult::Error(error_message)
} else {
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: None,
},
ctx
);
CallMCPToolResult::Success { result }
}
}
Err(e) => {
let error_message = e.to_string();
log::warn!("Executing MCP tool resulted in error: {e:?}");
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: Some(rmcp::RmcpError::Service(e).into()),
},
ctx
);
CallMCPToolResult::Error(error_message)
}
};
AIAgentActionResultType::CallMCPTool(action_result)
}
@@ -0,0 +1,48 @@
//! Unit tests for the `coerce_integer_args` helper.
use super::*;
use serde_json::json;
fn obj(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
match value {
serde_json::Value::Object(m) => m,
_ => panic!("expected a JSON object"),
}
}
#[test]
fn whole_float_is_coerced_when_schema_declares_integer() {
let mut args = obj(json!({ "line": 5.0 }));
let schema = obj(json!({
"properties": { "line": { "type": "integer" } }
}));
coerce_integer_args(&mut args, &schema);
// Serialized as "5", not "5.0", and round-trips as i64.
assert_eq!(serde_json::to_string(&args["line"]).unwrap(), "5");
assert_eq!(args["line"].as_i64(), Some(5));
}
#[test]
fn no_coercion_when_not_typed_as_integer() {
// Three scenarios that should all preserve the original float value:
// * schema declares `"type": "number"` (explicit float)
// * schema has no `properties` at all
// * schema property lacks a `"type"` key
let cases = [
json!({ "properties": { "x": { "type": "number" } } }),
json!({}),
json!({ "properties": { "x": { "description": "no type" } } }),
];
for schema_value in cases {
let mut args = obj(json!({ "x": 1.0 }));
let schema = obj(schema_value);
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_f64(), Some(1.0));
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "1.0");
}
}
@@ -0,0 +1,164 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{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},
execution_profiles::profiles::AIExecutionProfilesModel,
},
notebooks::editor::model::FileLinkResolutionContext,
terminal::model::session::active_session::ActiveSession,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct CreateDocumentsExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: warpui::EntityId,
}
impl CreateDocumentsExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: warpui::EntityId,
) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
id: action_id,
action: AIAgentActionType::CreateDocuments(CreateDocumentsRequest { documents }),
..
} = action
else {
return ActionExecution::<CreateDocumentsResult>::InvalidAction;
};
// Access the model synchronously before the async block
let model = AIDocumentModel::handle(ctx);
let created_documents: Vec<DocumentContext> = documents
.iter()
.enumerate()
.map(|(index, document)| {
// If we have streamed updates for this plan, just apply the last streamed update.
// A full reset would cause syntax highlighting in code blocks to flicker.
let existing_id = model
.as_ref(ctx)
.streaming_document_id_for_create_documents(&conversation_id, action_id, index);
let id = if let Some(existing_id) = existing_id {
model.update(ctx, |model, model_ctx| {
model.apply_streamed_agent_update(
&existing_id,
&document.title,
&document.content,
model_ctx,
);
});
existing_id
} else {
// If we weren't streaming updates for this document before, create the whole document now.
let session = self.active_session.as_ref(ctx);
let working_directory = session.current_working_directory().cloned();
let shell_launch_data = session.shell_launch_data(ctx);
let file_link_resolution_context =
working_directory.map(|working_directory| FileLinkResolutionContext {
working_directory,
shell_launch_data,
});
model.update(ctx, |model, model_ctx| {
model.create_document(
&document.title,
document.content.clone(),
conversation_id,
file_link_resolution_context.clone(),
model_ctx,
)
})
};
let profile = AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(self.terminal_view_id), ctx);
let should_autosync = profile.data().autosync_plans_to_warp_drive;
if should_autosync {
model.update(ctx, |model, model_ctx| {
model.sync_to_warp_drive(id, model_ctx);
});
}
// Add plan artifact to the conversation.
let artifact = Artifact::Plan {
document_uid: id.to_string(),
notebook_uid: None, // Will be updated when synced to Warp Drive
title: Some(document.title.clone()),
};
let terminal_view_id = self.terminal_view_id;
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
if let Some(conversation) = history.conversation_mut(&conversation_id) {
conversation.add_artifact(artifact, terminal_view_id, ctx);
}
});
// Read the actual content from the created document.
// The AIDocumentModel does some processing to remove additional newlines, since our rich text editor
// renders every newline as a linebreak.
let actual_content = model
.as_ref(ctx)
.get_document_content(&id, ctx)
.unwrap_or_else(|| document.content.clone());
DocumentContext {
document_id: id,
document_version: AIDocumentVersion::default(),
content: actual_content,
line_ranges: vec![],
}
})
.collect();
// Clear any streaming mappings for this action now that we've finalized results.
model.update(ctx, |model, ctx| {
model.clear_streaming_documents_for_action(&conversation_id, action_id, ctx);
});
ActionExecution::Sync(CreateDocumentsResult::Success { created_documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for CreateDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,154 @@
use ai::diff_validation::DiffDelta;
use futures::{future::BoxFuture, FutureExt};
use std::collections::HashMap;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest,
EditDocumentsResult,
},
document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentUpdateSource},
};
use crate::notebooks::post_process_notebook;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct EditDocumentsExecutor;
impl EditDocumentsExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action: AIAgentActionType::EditDocuments(EditDocumentsRequest { diffs }),
..
} = action
else {
return ActionExecution::<EditDocumentsResult>::InvalidAction;
};
let model = AIDocumentModel::handle(ctx);
let mut updated_documents = Vec::new();
let mut error_messages = Vec::new();
let mut document_deltas: HashMap<AIDocumentId, Vec<DiffDelta>> = HashMap::new();
// First pass: validate all diffs and accumulate deltas
for diff in diffs.iter() {
// Get current document content
let current_content = match model
.as_ref(ctx)
.get_document_content(&diff.document_id, ctx)
{
Some(content) => content,
None => {
error_messages.push(format!("Document {} does not exist.", diff.document_id));
continue;
}
};
// 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),
};
let content_name = format!("document_{}", diff.document_id);
let fuzzy_result = ai::diff_validation::fuzzy_match_diffs(
&content_name,
&[search_replace],
current_content,
);
// Check if diff application failed
if fuzzy_result.warrants_failure() {
let error_msg = if let Some(failures) = &fuzzy_result.failures {
if failures.fuzzy_match_failures > 0 {
format!(
"Could not apply diff to document {}: content mismatch",
diff.document_id
)
} else if failures.noop_deltas > 0 {
format!("Changes to document {} were already made", diff.document_id)
} else {
format!("Failed to apply diff to document {}", diff.document_id)
}
} else {
format!("Unknown diff failure for document {}", diff.document_id)
};
error_messages.push(error_msg);
continue;
}
// Accumulate deltas for this document
if let ai::diff_validation::DiffType::Update { deltas, .. } = fuzzy_result.diff_type {
document_deltas
.entry(diff.document_id)
.or_default()
.extend(deltas);
}
}
// If any diffs failed, don't apply any deltas.
// This result will be sent to the LLM as an error, so we don't want partial applications since they agent won't know about them.
if !error_messages.is_empty() {
let combined_errors = error_messages.join("\n");
return ActionExecution::Sync(EditDocumentsResult::Error(combined_errors).into());
}
// For every document, apply all deltas at once and collect updated documents for response
model.update(ctx, |model, model_ctx| {
for (document_id, deltas) in document_deltas {
if let Some(version) = model.create_new_version_and_apply_diffs(
&document_id,
deltas,
AIDocumentUpdateSource::Agent,
model_ctx,
) {
let new_content = model
.get_document_content(&document_id, model_ctx)
.unwrap_or_default();
updated_documents.push(DocumentContext {
document_id,
document_version: version,
content: new_content,
line_ranges: vec![],
});
}
}
});
// All diffs succeeded, return success
ActionExecution::Sync(EditDocumentsResult::Success { updated_documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for EditDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,110 @@
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 warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::AIAgentActionType;
use crate::BlocklistAIHistoryModel;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct FetchConversationExecutor;
impl FetchConversationExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::FetchConversation { conversation_id } = &action.action else {
return ActionExecution::<Option<CloudConversationData>>::InvalidAction;
};
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);
ActionExecution::new_async(load_future, move |cloud_conversation, _ctx| {
// TODO(REMOTE-1203): FetchConversation can't materialize non-Oz conversation transcripts yet.
let conversation = cloud_conversation.and_then(|cc| match cc {
CloudConversationData::Oz(c) => Some(c),
CloudConversationData::CLIAgent(_) => {
log::warn!("FetchConversation does not support CLI agent conversations");
None
}
});
materialize_conversation(conversation.map(|c| *c), &conversation_id)
})
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
/// Materializes a loaded conversation's tasks into YAML files on disk.
fn materialize_conversation(
conversation: Option<AIConversation>,
server_conversation_id: &str,
) -> AIAgentActionResultType {
let Some(conversation) = conversation else {
log::warn!("FetchConversation: failed to load conversation {server_conversation_id}");
return AIAgentActionResultType::FetchConversation(FetchConversationResult::Error(
format!("Failed to load conversation {server_conversation_id}"),
));
};
let tasks: Vec<warp_multi_agent_api::Task> = conversation
.all_tasks()
.filter_map(|task| task.source().cloned())
.collect();
log::info!(
"FetchConversation: materializing {} tasks for conversation {server_conversation_id}",
tasks.len(),
);
match conversation_yaml::materialize_tasks_to_yaml(&tasks) {
Ok(directory_path) => {
log::info!(
"FetchConversation: wrote YAML to {directory_path} \
for conversation {server_conversation_id}"
);
AIAgentActionResultType::FetchConversation(FetchConversationResult::Success {
directory_path,
})
}
Err(e) => {
log::error!("FetchConversation: failed to materialize YAML: {e}");
AIAgentActionResultType::FetchConversation(FetchConversationResult::Error(format!(
"Failed to materialize conversation: {e}"
)))
}
}
}
impl Entity for FetchConversationExecutor {
type Event = ();
}
@@ -0,0 +1,371 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use itertools::Itertools;
use warpui::r#async::FutureExt as AsyncFutureExt;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::{
conversation::AIConversationId, AIAgentAction, 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 warp_core::features::FeatureFlag;
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
use super::{
get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
};
pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) {
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_app_ctx!(TelemetryEvent::FileGlobToolFailed { server_output_id }, ctx);
}
impl FileGlobExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::FileGlob { path, .. }
| AIAgentActionType::FileGlobV2 {
search_dir: path, ..
},
..
},
conversation_id,
} = input
else {
return false;
};
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let absolute_path =
host_native_absolute_path(path.as_str(), &shell, &current_working_directory);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![PathBuf::from(absolute_path)],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action:
AIAgentActionType::FileGlob { patterns, path }
| AIAgentActionType::FileGlobV2 {
patterns,
search_dir: path,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let absolute_path = shell_native_absolute_path(
path.as_str(),
shell_launch_data.as_ref(),
current_working_directory.as_ref(),
);
let session = self.active_session.as_ref(ctx).session(ctx);
let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input);
ActionExecution::new_async(
async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
}
},
move |result, ctx| match result {
Ok(file_glob_result) => {
match file_glob_result {
FileGlobV2Result::Error(ref e) => {
log::warn!("Executing file_glob resulted in error: {e:?}");
log_file_glob_error(conversation_id_clone, ctx);
}
FileGlobV2Result::Success { .. } => {
send_telemetry_from_app_ctx!(
TelemetryEvent::FileGlobToolSucceeded,
ctx
);
}
_ => {}
}
// Convert FileGlobV2Result to FileGlobResult if the request was not V2.
if is_file_glob_v2 {
AIAgentActionResultType::FileGlobV2(file_glob_result)
} else {
AIAgentActionResultType::FileGlob(file_glob_result.into())
}
}
Err(e) => {
log::warn!("Failed to execute file_glob: {e:?}");
log_file_glob_error(conversation_id_clone, ctx);
if is_file_glob_v2 {
AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(e.to_string()))
} else {
AIAgentActionResultType::FileGlob(FileGlobResult::Error(e.to_string()))
}
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool {
self.active_session
.as_ref(ctx)
.session(ctx)
.is_some_and(|session| session.supports_parallel_command_execution())
}
}
fn is_file_glob_v2(input: &ExecuteActionInput) -> bool {
matches!(input.action.action, AIAgentActionType::FileGlobV2 { .. })
}
async fn run_file_glob(
patterns: Vec<String>,
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob"));
}
let Some(session) = session else {
return Err(anyhow::anyhow!("No session provided to file_glob"));
};
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
if is_in_git_repo {
run_git_ls_files_command(
&patterns,
&absolute_path,
session.as_ref(),
shell_launch_data,
)
.await
} else if session.shell().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
}
}
/// Uses git ls-files to list all files in a git repository and filters them by pattern.
async fn run_git_ls_files_command(
patterns: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
) -> 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_output = session
.execute_command(
command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let output = String::from_utf8_lossy(command_output.output()).to_string();
if command_output.success() {
// git ls-files outputs paths relative to the current directory. For consistency with the
// `find` and PowerShell implementations, convert to absolute paths.
let absolute_paths = non_empty_lines(&output)
.map(|relative_path| {
join_paths(&[target_path, relative_path], shell_launch_data.as_ref())
})
.map(|path| FileGlobV2Match { file_path: path });
Ok(FileGlobV2Result::Success {
matched_files: absolute_paths.collect(),
warnings: None,
})
} else {
Err(anyhow::anyhow!(output))
}
}
/// Uses the find command for Unix-like environments to find files matching patterns.
async fn run_find_command(
patterns: &[String],
target_path: &str,
session: &Session,
) -> 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 command_output = session
.execute_command(
find_command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let stdout = String::from_utf8_lossy(&command_output.stdout).to_string();
let stderr = String::from_utf8_lossy(&command_output.stderr).to_string();
let has_results = FeatureFlag::FileGlobV2Warnings.is_enabled() && !stdout.trim().is_empty();
if command_output.success() || has_results {
let files = non_empty_lines(&stdout).map(|line| FileGlobV2Match {
file_path: line.to_string(),
});
let warnings = if FeatureFlag::FileGlobV2Warnings.is_enabled() && !stderr.trim().is_empty()
{
Some(stderr)
} else {
None
};
Ok(FileGlobV2Result::Success {
matched_files: files.collect(),
warnings,
})
} else {
Err(anyhow::anyhow!(stderr))
}
}
/// Uses PowerShell's Get-ChildItem to find files matching patterns.
async fn run_powershell_get_childitem_command(
patterns: &[String],
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_output = session
.execute_command(
command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let output = String::from_utf8_lossy(command_output.output()).to_string();
if command_output.success() {
let files = non_empty_lines(&output).map(|line| FileGlobV2Match {
file_path: line.to_string(),
});
Ok(FileGlobV2Result::Success {
matched_files: files.collect(),
warnings: None,
})
} else {
Err(anyhow::anyhow!(output))
}
}
fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
str.lines().filter(|line| !line.is_empty())
}
impl Entity for FileGlobExecutor {
type Event = ();
}
@@ -0,0 +1,334 @@
use std::path::{Path, PathBuf};
use futures::{future::BoxFuture, FutureExt};
use itertools::Itertools;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionResultType,
AIAgentActionType, FileLocations, GetFilesRequestType, GetFilesResult,
},
blocklist::BlocklistAIPermissions,
get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesStatus,
},
paths::host_native_absolute_path,
},
terminal::model::session::active_session::ActiveSession,
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
pub struct GetFilesExecutor {
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
}
impl GetFilesExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
) -> Self {
Self {
active_session,
get_relevant_files_controller,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::GetFiles(get_files_request),
..
},
conversation_id,
} = input
else {
return false;
};
// TODO: figure out how to avoid constructing the full paths in `should_execute`
// and then again in `execute`, and then again on every render.
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
match get_files_request {
GetFilesRequestType::RelevantFileQuery { .. } => {
match self.get_relevant_files_controller.as_ref(ctx).status(id) {
Some(relevant_files_status) => match relevant_files_status {
GetRelevantFilesStatus::Pending { root_repo_path } => {
// If we have access to read the repo, we can auto-execute the search.
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![root_repo_path.to_owned()],
self.terminal_view_id,
ctx,
)
.is_allowed()
}
// We can autoexecute if the request has not yet been sent or has failed,
// in which case we report the failure back to the LLM.
GetRelevantFilesStatus::InFlight { .. }
| GetRelevantFilesStatus::Failed { .. } => true,
GetRelevantFilesStatus::Success { file_paths, .. } => {
// If we've retrieved the relevant file paths, auto-execution (to read
// the file contents) depends on the user's permission.
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
file_paths
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.as_os_str().to_string_lossy(),
&shell,
&current_working_directory,
))
})
.collect(),
self.terminal_view_id,
ctx,
)
.is_allowed()
}
},
// Shouldn't be possible.
None => false,
}
}
GetFilesRequestType::FileLocations(file_locations) => {
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
file_locations
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.name,
&shell,
&current_working_directory,
))
})
.collect(),
self.terminal_view_id,
ctx,
)
.is_allowed()
}
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
id,
action: AIAgentActionType::GetFiles(get_files_request),
..
} = action
else {
return ActionExecution::InvalidAction;
};
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::GetFiles(
GetFilesResult::Error(
"The search failed. Try another way to locate the relevant files.".to_string(),
),
));
};
match get_files_request {
GetFilesRequestType::RelevantFileQuery {
query,
partial_paths,
} => {
match self
.get_relevant_files_controller
.as_ref(ctx)
.status(id)
.cloned()
{
Some(GetRelevantFilesStatus::Pending { root_repo_path }) => {
// 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_repo_path.to_owned()],
);
});
// Start the actual search.
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
&current_working_directory,
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::NotReady,
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()
}
GetRelevantFilesError::CreateFailed => {
"Relevant file search in the current directory is not available".to_owned()
}
GetRelevantFilesError::Missing => {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
};
ActionExecution::Sync(AIAgentActionResultType::GetFiles(
GetFilesResult::Error(error_message),
))
}
}
}
Some(GetRelevantFilesStatus::InFlight { .. }) => ActionExecution::NotReady,
// The search succeeded so now we can look up the specific files.
Some(GetRelevantFilesStatus::Success { file_paths, .. }) => self
.execute_get_file_by_location_action(
file_paths
.iter()
.map(|path| FileLocations {
name: path.to_string_lossy().to_string(),
lines: vec![],
})
.collect_vec(),
conversation_id,
ctx,
),
Some(GetRelevantFilesStatus::Failed { .. }) => ActionExecution::Sync(
AIAgentActionResultType::GetFiles(GetFilesResult::Error(
"The search failed. Try another way to locate the relevant files."
.to_owned(),
)),
),
None => {
log::warn!(
"Tried to execute a GetFiles action without a corresponding status"
);
ActionExecution::InvalidAction
}
}
}
GetFilesRequestType::FileLocations(file_locations) => self
.execute_get_file_by_location_action(file_locations.clone(), conversation_id, ctx),
}
}
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let Some(pwd) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned()
else {
log::warn!("Failed to preprocess GetRelevantFiles action because no pwd");
return futures::future::ready(()).boxed();
};
self.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.queue_request(input.action.id.clone(), Path::new(&pwd), ctx);
});
futures::future::ready(()).boxed()
}
fn execute_get_file_by_location_action(
&self,
files: Vec<FileLocations>,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> ActionExecution<anyhow::Result<GetFilesResult>> {
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
files.iter().map(|file| Path::new(&file.name)),
);
});
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
ActionExecution::Async {
execute_future: Box::pin(async move {
let result =
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,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(GetFilesResult::Error(format!(
"These files do not exist: {}",
missing_files
)))
}
}),
on_complete: Box::new(|res, _ctx| {
let action_result = res.unwrap_or_else(|e| GetFilesResult::Error(e.to_string()));
AIAgentActionResultType::GetFiles(action_result)
}),
}
}
}
impl Entity for GetFilesExecutor {
type Event = ();
}
@@ -0,0 +1,695 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use warp_util::standardized_path::StandardizedPath;
use futures::future::BoxFuture;
use futures::FutureExt;
use warpui::r#async::FutureExt as AsyncFutureExt;
use warpui::{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,
};
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 {
command: Option<String>,
output: Option<String>,
/// The error message from the Grep call. This should NOT contain UGC.
error: GrepErrorType,
}
enum GrepErrorType {
NonZeroExitCode,
Other(String),
}
impl GrepError {
/// Create a new GrepError with the given error message. This should NOT
/// contain UGC.
pub fn new(error_message: String) -> Self {
Self {
command: None,
output: None,
error: GrepErrorType::Other(error_message),
}
}
pub fn new_for_non_zero_exit_code() -> Self {
Self {
command: None,
output: None,
error: GrepErrorType::NonZeroExitCode,
}
}
pub fn with_command(mut self, command: String) -> Self {
self.command = Some(command);
self
}
pub fn with_output(mut self, output: String) -> Self {
self.output = Some(output);
self
}
/// Returns an error message for logging. This should not contain UGC.
pub fn error_message(&self) -> &str {
match &self.error {
GrepErrorType::NonZeroExitCode => NON_ZERO_EXIT_CODE_ERROR,
GrepErrorType::Other(error) => error,
}
}
/// Returns an error message to be returned as input to the AI conversation.
/// This may contain UGC.
pub fn error_for_conversation(&self) -> String {
match &self {
GrepError {
error: GrepErrorType::NonZeroExitCode,
output: Some(output),
..
} => format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}"),
GrepError {
error: GrepErrorType::NonZeroExitCode,
output: None,
..
} => NON_ZERO_EXIT_CODE_ERROR.to_string(),
GrepError {
error: GrepErrorType::Other(error),
..
} => error.clone(),
}
}
}
#[allow(clippy::too_many_arguments)]
fn create_redacted_grep_error_event(
should_collect_ugc: bool,
server_output_id: Option<ServerOutputId>,
mut queries: Vec<String>,
mut path: String,
shell_type: Option<ShellType>,
mut working_directory: Option<String>,
mut absolute_path: String,
mut error: GrepError,
) -> TelemetryEvent {
for query in queries.iter_mut() {
redact_secrets(query);
}
redact_secrets(&mut path);
if let Some(working_directory) = working_directory.as_mut() {
redact_secrets(working_directory);
}
redact_secrets(&mut absolute_path);
if let Some(command) = error.command.as_mut() {
redact_secrets(command);
}
if let Some(output) = error.output.as_mut() {
redact_secrets(output);
}
TelemetryEvent::GrepToolFailed {
queries: should_collect_ugc.then_some(queries),
path: should_collect_ugc.then_some(path),
shell_type,
working_directory: should_collect_ugc.then_some(working_directory).flatten(),
absolute_path: should_collect_ugc.then_some(absolute_path),
error: error.error_message().to_string(),
command: should_collect_ugc.then_some(error.command).flatten(),
output: should_collect_ugc.then_some(error.output).flatten(),
server_output_id,
}
}
#[allow(clippy::too_many_arguments)]
fn log_grep_error(
conversation_id: AIConversationId,
queries: Vec<String>,
path: String,
shell_type: Option<ShellType>,
working_directory: Option<String>,
absolute_path: String,
error: GrepError,
ctx: &mut AppContext,
) {
let should_collect_ugc = should_collect_ai_ugc_telemetry(
ctx,
PrivacySettings::handle(ctx)
.as_ref(ctx)
.is_telemetry_enabled,
);
let server_output_id = get_server_output_id(conversation_id, ctx);
let event = create_redacted_grep_error_event(
should_collect_ugc,
server_output_id,
queries,
path,
shell_type,
working_directory,
absolute_path,
error,
);
send_telemetry_from_app_ctx!(event, ctx);
}
pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
impl GrepExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::Grep { path, .. },
..
},
conversation_id,
} = input
else {
return false;
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let absolute_path = host_native_absolute_path(path, &shell, &current_working_directory);
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![PathBuf::from(absolute_path)],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::Grep { queries, path },
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_type = self.active_session.as_ref(ctx).shell_type(ctx);
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let absolute_path = shell_native_absolute_path(
path,
shell_launch_data.as_ref(),
current_working_directory.as_ref(),
);
let session = self.active_session.as_ref(ctx).session(ctx);
let path_clone = path.clone();
let queries_clone = queries.clone();
let other_queries_clone = queries.clone();
let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id;
ActionExecution::new_async(
async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data)
.with_timeout(GREP_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
}
},
move |result, ctx| match result {
Ok(grep_result) => {
match grep_result {
GrepResult::Error(ref e) => {
log::warn!("Executing grep resulted in error: {e:?}");
log_grep_error(
conversation_id_clone,
other_queries_clone,
path_clone,
shell_type,
working_directory_clone,
absolute_path_clone,
GrepError::new(e.to_string()),
ctx,
);
}
GrepResult::Success { .. } => {
send_telemetry_from_app_ctx!(TelemetryEvent::GrepToolSucceeded, ctx);
}
_ => {}
}
AIAgentActionResultType::Grep(grep_result)
}
Err(e) => {
log::warn!("Failed to execute grep: {:?}", e.error_message());
let error_for_conversation = e.error_for_conversation();
log_grep_error(
conversation_id_clone,
other_queries_clone,
path_clone,
shell_type,
working_directory_clone,
absolute_path_clone,
e,
ctx,
);
AIAgentActionResultType::Grep(GrepResult::Error(error_for_conversation))
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool {
self.active_session
.as_ref(ctx)
.session(ctx)
.is_some_and(|session| session.supports_parallel_command_execution())
}
}
/// Runs a grep-like search to find the files and line numbers that match the queries.
///
/// Depending on the environment, this uses the most optimized tool to perform the search:
/// - if the search is in a git repo, we run `git grep` in the session.
/// `git grep` is the most optimized tool for searching in a git repo since it's already indexed.
/// - otherwise, if the search is against the local file system, we run `ripgrep` via the library.
/// `ripgrep` is a more optimized version of `grep`.
/// - otherwise, we run vanilla `grep` in the session
async fn run_grep(
queries: Vec<String>,
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
) -> Result<GrepResult, GrepError> {
if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string()));
}
let Some(session) = session else {
return Err(GrepError::new("No session provided to grep".to_string()));
};
let is_file = is_file_path(&absolute_path, &session).await;
let execute_directory = if is_file {
// If path is a file, use its parent directory as the execution directory.
// Use StandardizedPath instead of std::path::Path to avoid encoding a
// remote path with the local platform's path separators.
let Ok(standardized) = StandardizedPath::try_new(&absolute_path) else {
return Err(GrepError::new(
"Could not determine parent directory of file when running grep".to_string(),
));
};
let Some(parent) = standardized.parent() else {
return Err(GrepError::new(
"Could not determine parent directory of file when running grep".to_string(),
));
};
Cow::Owned(parent.as_str().to_owned())
} else {
Cow::Borrowed(absolute_path.as_str())
};
// TODO(CODE-239): Cache the result of this check.
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`;
// whether the session is local or remote, we can run `git grep` in the session.
// The next best way to search is ripgrep, but we can only run that if the session is local;
// ripgrep is run using the core lib, not as a command (not everyone will have it installed).
// And in the worst case, we run vanilla `grep` in the session. Although not optimal, this should always work.
if is_grep_in_git_repo {
run_git_grep_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
shell_type,
&execute_directory,
)
.await
} else {
#[cfg(not(target_family = "wasm"))]
if session.is_local() {
return run_ripgrep(&queries, absolute_path).await;
}
if shell_type == ShellType::PowerShell {
run_select_string_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
&execute_directory,
)
.await
} else {
run_grep_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
&execute_directory,
)
.await
}
}
}
#[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path);
let result = warp_ripgrep::search::search(queries, &[path], false, false).await;
match result {
Ok(matches) => {
let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches {
files_map
.entry(m.file_path)
.or_default()
.push(GrepLineMatch {
line_number: m.line_number as usize,
});
}
let matched_files: Vec<GrepFileMatch> = files_map
.into_iter()
.map(|(file_path, matched_lines)| GrepFileMatch {
file_path: file_path.to_string_lossy().to_string(),
matched_lines,
})
.collect();
Ok(GrepResult::Success { matched_files })
}
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
}
}
/// Assumes that git is installed in the user's session.
async fn run_git_grep_command(
queries: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
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 command_output = session
.execute_command(
grep_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(grep_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
.with_output(output.into())
})
} else if command_output
.exit_code()
.is_some_and(|exit_code| exit_code.value() == 1)
{
// If the exit code is 1, then grep completed successfully but found no
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(grep_command)
.with_output(output.into()))
}
}
async fn run_grep_command(
queries: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
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 command_output = session
.execute_command(
grep_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(grep_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
.with_output(output.into())
})
} else if command_output
.exit_code()
.is_some_and(|exit_code| exit_code.value() == 1)
{
// If the exit code is 1, then grep completed successfully but found no
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(grep_command)
.with_output(output.into()))
}
}
/// Runs a PowerShell `Select-String` command.
async fn run_select_string_command(
queries: &[String],
target_path: &str,
session: &Session,
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 command_output = session
.execute_command(
select_string_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(select_string_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(select_string_command)
.with_output(output.into())
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(select_string_command)
.with_output(output.into()))
}
}
/// Parses the output of grep or a grep-like command into the format that we pass
/// back to the agent.
///
/// Assumes the output is in the format:
/// `{relative_file_path}:{line_number}:{line_contents}`.
fn parse_grep_output(
output: &str,
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> {
let mut matched_files = HashMap::new();
for line in output.trim().split("\n") {
let mut parts = line.split(":");
let file = parts.next();
let line_number = parts.next();
let (Some(file), Some(line_number)) = (file, line_number) else {
return Err(anyhow::anyhow!(
"Failed to parse Grep output, unexpected format"
));
};
let line_number = match line_number.parse::<usize>() {
Ok(line_number) => line_number,
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to parse line number in Grep output: {:?}",
e
));
}
};
matched_files
.entry(file)
.or_insert_with(Vec::new)
.push(GrepLineMatch { line_number });
}
Ok(matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect())
}
impl Entity for GrepExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "grep_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use super::*;
use crate::terminal::{model::secrets::regexes::FIREBASE_AUTH_DOMAIN, shell::ShellType};
#[test]
fn test_create_redacted_grep_error_event() {
crate::terminal::model::set_user_and_enterprise_secret_regexes(
[&regex::Regex::new(FIREBASE_AUTH_DOMAIN).expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Create input with a known secret pattern (Firebase domain)
let queries = vec![
"normal query".to_string(),
"query with warp-server-staging.firebaseapp.com secret".to_string(),
];
let path = "path/to/file/with/warp-server-staging.firebaseapp.com/secret".to_string();
let shell_type = Some(ShellType::Bash);
let working_directory = Some("/users/test/warp-server-staging.firebaseapp.com".to_string());
let absolute_path =
"/absolute/path/with/warp-server-staging.firebaseapp.com/secret".to_string();
let error = GrepError::new("Error message".to_string())
.with_command("grep warp-server-staging.firebaseapp.com".to_string())
.with_output("Output with warp-server-staging.firebaseapp.com".to_string());
// Call the function with the test inputs
let event = create_redacted_grep_error_event(
true,
None,
queries.clone(),
path.clone(),
shell_type,
working_directory.clone(),
absolute_path.clone(),
error,
);
// Verify the telemetry event has redacted secrets
if let TelemetryEvent::GrepToolFailed {
queries: Some(redacted_queries),
path: Some(redacted_path),
shell_type: _,
working_directory: Some(redacted_working_directory),
absolute_path: Some(redacted_absolute_path),
command: Some(redacted_command),
output: Some(redacted_output),
error: _,
server_output_id: _,
} = event
{
// Verify secrets are redacted from all relevant fields
assert_eq!(redacted_queries.len(), 2);
assert_eq!(redacted_queries[0], "normal query");
assert!(!redacted_queries[1].contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_queries[1].contains("*****"));
assert!(!redacted_path.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_path.contains("*****"));
assert!(!redacted_working_directory.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_working_directory.contains("*****"));
assert!(!redacted_absolute_path.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_absolute_path.contains("*****"));
assert!(!redacted_command.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_command.contains("*****"));
assert!(!redacted_output.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_output.contains("*****"));
} else {
panic!("Expected GrepToolFailed event");
}
}
@@ -0,0 +1,75 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest,
ReadDocumentsResult,
},
document::ai_document_model::AIDocumentModel,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct ReadDocumentsExecutor;
impl ReadDocumentsExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest { document_ids }),
..
} = action
else {
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();
ActionExecution::Sync(ReadDocumentsResult::Success { documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,275 @@
use std::path::{Path, PathBuf};
use futures::{future::BoxFuture, FutureExt};
use warpui::{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,
};
pub struct ReadFilesExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
impl ReadFilesExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }),
..
},
conversation_id,
} = input
else {
return false;
};
// TODO: figure out how to avoid constructing the full paths in `should_execute`
// and then again in `execute`, and then again on every render.
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
locations
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.name,
&shell,
&current_working_directory,
))
})
.collect(),
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }),
..
} = action
else {
return ActionExecution::InvalidAction;
};
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
locations.iter().map(|file| Path::new(&file.name)),
);
});
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let locations = locations.clone();
// 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 {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned(),
_ => None,
};
// Remote session without a usable remote server client. 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()
{
return ActionExecution::Sync(AIAgentActionResultType::ReadFiles(
ReadFilesResult::Error(
"The file read/edit tool is not available on this remote session. \
Try using a different tool."
.to_string(),
),
));
}
if let Some(client) = remote_client {
return ActionExecution::Async {
execute_future: Box::pin(async move {
let request = remote_server::proto::ReadFileContextRequest {
files: locations
.iter()
.map(|loc| {
let absolute_path = host_native_absolute_path(
&loc.name,
&shell,
&current_working_directory,
);
remote_server::proto::ReadFileContextFile {
path: absolute_path,
line_ranges: loc
.lines
.iter()
.map(|r| remote_server::proto::LineRange {
start: r.start as u32,
end: r.end as u32,
})
.collect(),
}
})
.collect(),
max_file_bytes: None,
max_batch_bytes: None,
};
let response = client
.read_file_context(request)
.await
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
if !response.failed_files.is_empty() && response.file_contexts.is_empty() {
let failed = response
.failed_files
.iter()
.map(|f| {
let reason = f
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown error");
format!("{}: {reason}", f.path)
})
.collect::<Vec<_>>()
.join(", ");
return Ok(ReadFilesResult::Error(format!(
"Failed to read files: {failed}"
)));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(
text,
) => crate::ai::agent::AnyFileContent::StringContent(text),
remote_server::proto::file_context_proto::Content::BinaryContent(
bytes,
) => crate::ai::agent::AnyFileContent::BinaryContent(bytes),
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc.last_modified_epoch_millis.map(|ms| {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms)
});
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
Ok(ReadFilesResult::Success {
files: file_contexts,
})
}),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result =
res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
AIAgentActionResultType::ReadFiles(action_result)
}),
};
}
// Local path.
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await?;
if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success {
files: result.file_contexts,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(ReadFilesResult::Error(format!(
"These files do not exist: {missing_files}"
)))
}
}),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
AIAgentActionResultType::ReadFiles(action_result)
}),
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadFilesExecutor {
type Event = ();
}
@@ -0,0 +1,164 @@
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 warpui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use crate::ai::{
agent::{AIAgentActionResultType, ReadMCPResourceResult},
blocklist::{
action_model::{AIAgentAction, AIAgentActionType},
BlocklistAIPermissions,
},
};
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
pub struct ReadMCPResourceExecutor {
_active_session: ModelHandle<ActiveSession>,
#[cfg_attr(target_family = "wasm", expect(unused))]
terminal_view_id: EntityId,
}
impl ReadMCPResourceExecutor {
pub fn new(_active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
_active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::ReadMCPResource {
server_id,
name,
uri,
..
},
..
},
conversation_id,
} = input
else {
return false;
};
BlocklistAIPermissions::as_ref(ctx).can_read_mcp_resource(
server_id.as_ref(),
name.as_str(),
uri.as_deref(),
&conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action:
AIAgentActionType::ReadMCPResource {
server_id: _,
name,
uri,
},
..
} = action
else {
return ActionExecution::InvalidAction;
};
let templatable_mcp_client = TemplatableMCPServerManager::as_ref(ctx);
let resource = match uri {
Some(uri) => templatable_mcp_client
.resources()
.find(|resource| &resource.uri == uri),
None => templatable_mcp_client
.resources()
.find(|resource| &resource.name == name),
};
let Some(resource) = resource else {
return ActionExecution::Sync(AIAgentActionResultType::ReadMCPResource(
ReadMCPResourceResult::Error("MCP server resource not found".to_owned()),
));
};
let uri = resource.uri.clone();
let Some(reconnecting_peer) = templatable_mcp_client.server_with_resource(resource)
else {
return ActionExecution::Sync(AIAgentActionResultType::ReadMCPResource(
ReadMCPResourceResult::Error("MCP server for resource not found".to_owned()),
));
};
ActionExecution::new_async(
async move {
reconnecting_peer
.read_resource(rmcp::model::ReadResourceRequestParam { uri })
.await
},
|res, _ctx| handle_read_resource_result(res),
)
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadMCPResourceExecutor {
type Event = ();
}
/// Handles the result of a read_resource request, converting it to an AIAgentActionResultType.
#[cfg(not(target_family = "wasm"))]
fn handle_read_resource_result(
res: Result<rmcp::model::ReadResourceResult, rmcp::ServiceError>,
) -> AIAgentActionResultType {
let action_result = match res {
Ok(response) => ReadMCPResourceResult::Success {
resource_contents: response.contents,
},
Err(e) => ReadMCPResourceResult::Error(e.to_string()),
};
AIAgentActionResultType::ReadMCPResource(action_result)
}
@@ -0,0 +1,94 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::skills::{SkillManager, SkillTelemetryEvent};
use crate::send_telemetry_from_ctx;
use ai::agent::action_result::AnyFileContent;
use warpui::{ModelContext, SingletonEntity};
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 warpui::Entity;
pub struct ReadSkillExecutor;
impl ReadSkillExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// User-created skills are readable on demand.
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::ReadSkill(ReadSkillRequest { skill: skill_ref }) = &action.action
else {
return ActionExecution::<ReadSkillResult>::InvalidAction;
};
match SkillManager::as_ref(ctx).skill_by_reference(skill_ref) {
Some(skill) => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
name: Some(skill.name.clone()),
scope: Some(skill.scope),
provider: Some(skill.provider),
error: false,
},
ctx
);
let content = FileContext::new(
skill.path.to_string_lossy().into_owned(),
AnyFileContent::StringContent(skill.content.clone()),
skill.line_range.clone(),
None,
);
ActionExecution::Sync(ReadSkillResult::Success { content }.into())
}
None => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
name: None,
scope: None,
provider: None,
error: true,
},
ctx
);
ActionExecution::Sync(
ReadSkillResult::Error(format!("Skill not found: {:?}", skill_ref)).into(),
)
}
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadSkillExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "read_skill_tests.rs"]
mod tests;
@@ -0,0 +1,142 @@
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::warp_managed_paths_watcher::WarpManagedPathsWatcher;
use ai::skills::{parse_skill, SkillReference};
use repo_metadata::{
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
};
use std::fs;
use std::io::Write;
use tempfile::TempDir;
use warpui::App;
use watcher::HomeDirectoryWatcher;
fn initialize_app(app: &mut App) {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(SkillManager::new);
}
fn create_test_skill_file(dir: &TempDir, name: &str, description: &str) -> std::path::PathBuf {
let skill_content = format!(
r#"---
name: {}
description: {}
---
# {}
## Instructions
Test instructions for this skill.
## Examples
Example usage of the skill.
"#,
name, description, name
);
let skill_dir = dir.path().join(format!(".claude/skills/{}", name));
fs::create_dir_all(&skill_dir).unwrap();
let skill_path = skill_dir.join("SKILL.md");
let mut file = fs::File::create(&skill_path).unwrap();
file.write_all(skill_content.as_bytes()).unwrap();
file.flush().unwrap();
skill_path
}
#[test]
fn test_read_skill_executor_success() {
let temp_dir = TempDir::new().unwrap();
let skill_path = create_test_skill_file(&temp_dir, "test-skill", "A test skill");
App::test((), |mut app| async move {
initialize_app(&mut app);
// Populate SkillManager cache with the test skill
let parsed_skill = parse_skill(&skill_path).expect("Failed to parse test skill");
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_skill_for_testing(parsed_skill);
});
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path.clone()),
}),
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, skill_path.to_string_lossy().to_string());
}
_ => panic!("Successfully read skill file; should return ReadSkillResult::Success"),
}
});
});
}
#[test]
fn test_read_skill_executor_file_not_found() {
let temp_dir = TempDir::new().unwrap();
// Don't create the SKILL.md file
let skill_path = temp_dir.path().join("SKILL.md");
App::test((), |mut app| async move {
initialize_app(&mut app);
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path),
}),
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::Error(error_msg),
)) => {
// Should contain an error about file not found or I/O error
assert!(!error_msg.is_empty());
}
_ => panic!(
"Nonexistent SKILL.md file at given path; should return ReadSkillResult::Error"
),
}
});
});
}
@@ -0,0 +1,137 @@
use std::collections::HashSet;
use ai::agent::action_result::{AIAgentActionResultType, RequestComputerUseResult};
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
use crate::ai::agent::{AIAgentActionId, AIAgentActionType};
use crate::ai::ambient_agents::AmbientAgentTaskId;
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>,
/// Actions that were determined to be auto-executed in should_autoexecute().
/// Used to determine is_autoexecuted when emitting telemetry in execute().
autoexecuted_actions: HashSet<AIAgentActionId>,
}
impl RequestComputerUseExecutor {
pub fn new(terminal_view_id: EntityId) -> Self {
Self {
terminal_view_id,
ambient_agent_task_id: None,
autoexecuted_actions: HashSet::new(),
}
}
pub fn set_ambient_agent_task_id(&mut self, id: Option<AmbientAgentTaskId>) {
self.ambient_agent_task_id = id;
}
pub(super) fn should_autoexecute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::RequestComputerUse(_) = &action.action else {
return false;
};
// Check profile permission
let permission = crate::ai::blocklist::BlocklistAIPermissions::as_ref(ctx)
.get_computer_use_setting(ctx, Some(self.terminal_view_id));
if permission.is_always_allow() {
// Track that this action was auto-executed for telemetry in execute()
self.autoexecuted_actions.insert(action.id.clone());
return true;
}
// Otherwise require user confirmation for computer use.
false
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
} = input;
let AIAgentActionType::RequestComputerUse(request) = &action.action else {
return ActionExecution::InvalidAction;
};
// If we're executing, that implies that computer use has been approved.
let is_autoexecuted = self.autoexecuted_actions.remove(&action.id);
send_telemetry_from_ctx!(
TelemetryEvent::ComputerUseApproved {
conversation_id,
is_autoexecuted,
ambient_agent_task_id: self.ambient_agent_task_id,
},
ctx
);
let screenshot_params = request.screenshot_params;
let mut actor = computer_use::create_actor();
let platform = actor.platform();
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = actor
.perform_actions(&[], computer_use::Options { screenshot_params })
.await;
(result, platform)
}),
on_complete: Box::new(|action_result, _ctx| match action_result {
(
Ok(computer_use::ActionResult {
screenshot: Some(screenshot),
..
}),
Some(platform),
) => AIAgentActionResultType::RequestComputerUse(
RequestComputerUseResult::Approved {
screenshot,
platform,
},
),
(
Ok(computer_use::ActionResult {
screenshot: Some(_),
..
}),
None,
) => AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Error(
"Unknown platform".to_string(),
)),
(Ok(_), _) => {
AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Error(
"Failed to capture initial screenshot".to_string(),
))
}
(Err(err), _) => AIAgentActionResultType::RequestComputerUse(
RequestComputerUseResult::Error(err),
),
}),
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for RequestComputerUseExecutor {
type Event = ();
}
@@ -0,0 +1,445 @@
mod apply_diff_model;
mod diff_application;
mod telemetry;
use warp_util::file::FileSaveError;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::diff_validation::AIRequestedCodeDiff;
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use itertools::Itertools;
use vec1::{vec1, Vec1};
use warp_core::send_telemetry_from_ctx;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
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 telemetry::MalformedFinalLineProxyEvent;
#[allow(unused_imports)]
pub use telemetry::{EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent};
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 super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>,
apply_diff_model: ModelHandle<ApplyDiffModel>,
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
/// Set of action IDs where diff application failed.
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
terminal_view_id: EntityId,
}
impl RequestFileEditsExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
let apply_diff_model = ctx.add_model(|_| ApplyDiffModel::new(active_session.clone()));
Self {
active_session,
apply_diff_model,
diff_views: HashMap::new(),
diff_application_failures: HashMap::new(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::RequestFileEdits { file_edits, .. },
..
},
conversation_id,
} = input
else {
return false;
};
let paths: Vec<PathBuf> = file_edits
.iter()
.filter_map(|edit| edit.file().map(PathBuf::from))
.collect();
// Don't allow autoexecution if the diff was generated passively.
let Some(latest_exchange) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.latest_exchange())
else {
return false;
};
if latest_exchange.has_passive_request() {
return false;
}
// Allow "autoexecution" if the diff application failed so that we can continue execution.
// This is a terrible hack--but allows us to continue execution and let the LLM potentially recover
// from the LLM.
// If we don't do this, a failed diff application will block execution of the entire AI conversation
// without any possibility of recovery.
if self
.diff_application_failures
.contains_key(&input.action.id)
{
return true;
}
BlocklistAIPermissions::as_ref(ctx)
.can_write_files(&conversation_id, &paths, Some(self.terminal_view_id), ctx)
.is_allowed()
}
/// Registers a diff view to handle a RequestFileEdits action.
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in
/// order for the necessary state to be set to handle the action.
pub fn register_requested_edits(
&mut self,
action_id: &AIAgentActionId,
view: &ViewHandle<CodeDiffView>,
) {
self.diff_views.insert(action_id.clone(), view.clone());
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::RequestFileEdits { .. },
..
},
..
} = input
else {
return ActionExecution::InvalidAction;
};
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!("Tried to execute a RequestFileEdits action without a diff view");
return ActionExecution::NotReady;
};
// If diff application failed, early exit.
if let Some(errors) = self.diff_application_failures.remove(id) {
return ActionExecution::Sync(AIAgentActionResultType::RequestFileEdits(
RequestFileEditsResult::DiffApplicationFailed {
error: DiffApplicationError::error_for_conversation(&errors),
},
));
}
let identifiers = self
.generate_ai_identifiers(&input.conversation_id, id, ctx)
.unwrap_or_else(|| AIIdentifiers {
client_conversation_id: Some(input.conversation_id),
..Default::default()
});
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 {
CodeDiffViewEvent::Rejected => {
let Some(result_tx) = result_tx.take() else {
return;
};
let _ = result_tx.send(RequestFileEditsResult::Cancelled);
}
CodeDiffViewEvent::SavedAcceptedDiffs {
diff,
updated_files,
file_contents,
deleted_files,
save_errors,
} => {
let Some(result_tx) = result_tx.take() else {
return;
};
// If saving any file failed, report it as an error to the LLM. Other files may
// have saved successfully, but we're ignoring this edge case for now.
if !save_errors.is_empty() {
let error = save_errors
.iter()
.filter_map(|err| match err.as_ref() {
FileSaveError::IOError { error, path } => {
Some(format!("Failed to save file {path:?}: {error}"))
}
_ => None,
})
.join("\n");
let _ = result_tx.send(RequestFileEditsResult::DiffApplicationFailed { error });
return;
}
let passive_diff = BlocklistAIHistoryModel::as_ref(ctx)
.is_entirely_passive_conversation(&input.conversation_id);
send_telemetry_from_ctx!(
RequestFileEditsTelemetryEvent::EditResolved(EditResolvedEvent {
identifiers: identifiers.clone(),
response: RequestedEditResolution::Accept,
stats: EditStats {
files_edited: updated_files.len(),
lines_added: diff.lines_added,
lines_removed: diff.lines_removed,
},
passive_diff,
},),
ctx
);
// Build a map of file path → content from the editor buffers.
// 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(),
deleted_files: deleted_files.clone(),
lines_added: diff.lines_added,
lines_removed: diff.lines_removed,
});
}
_ => (),
});
diff_view.update(ctx, |diff_view, ctx| {
diff_view.accept_and_save(ctx);
});
ActionExecution::new_async(result_rx, |result, _ctx| match result {
Ok(result) => AIAgentActionResultType::RequestFileEdits(result),
Err(oneshot::Canceled) => {
AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::Cancelled)
}
})
}
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let AIAgentAction {
id,
action: AIAgentActionType::RequestFileEdits { file_edits, .. },
..
} = input.action
else {
return futures::future::ready(()).boxed();
};
let ai_identifiers = self
.generate_ai_identifiers(&input.conversation_id, id, ctx)
.unwrap_or_else(|| AIIdentifiers {
client_conversation_id: Some(input.conversation_id),
..Default::default()
});
let passive_diff = BlocklistAIHistoryModel::as_ref(ctx)
.is_entirely_passive_conversation(&input.conversation_id);
send_telemetry_from_ctx!(
RequestFileEditsTelemetryEvent::EditReceived(EditReceivedEvent {
identifiers: ai_identifiers.clone(),
unique_files: file_edits.iter().map(|file| file.file()).unique().count(),
diffs: file_edits.len(),
passive_diff,
}),
ctx
);
let (tx, rx) = oneshot::channel();
let files = file_edits.clone();
let id = id.clone();
let apply_future = self.apply_diff_model.update(ctx, |model, ctx| {
model.apply_diffs(files, &ai_identifiers, passive_diff, ctx)
});
ctx.spawn(
async move {
let applied_diffs = apply_future.await;
(applied_diffs, id, tx)
},
|me, (diffs, id, tx), ctx| {
me.on_diffs_applied(diffs, id, tx, ctx);
},
);
async {
rx.await.ok();
}
.boxed()
}
fn on_diffs_applied(
&mut self,
applied_diffs: Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>,
id: AIAgentActionId,
tx: oneshot::Sender<()>,
ctx: &mut ModelContext<Self>,
) {
tx.send(()).ok();
let Some(diff_view) = self.diff_views.get(&id) else {
log::warn!(
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view"
);
return;
};
let applied_diffs = match applied_diffs {
Ok(diffs) if !diffs.is_empty() => diffs,
Ok(_) => {
// We didn't generate any diffs--consider this a failure.
log::warn!("No diffs generated");
self.diff_application_failures
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
return;
}
Err(err) => {
safe_warn!(
safe: ("Failed to generate diffs"),
full: ("Failed to generate diffs {err:?}")
);
self.diff_application_failures.insert(id, err);
return;
}
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let mut diffs = Vec::with_capacity(applied_diffs.len());
for diff in applied_diffs {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
diffs.push(file_diff);
}
// Set the session type on the diff view so save/delete/create routes
// through the correct FileModel backend.
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
diff_view.update(ctx, |diff_view, ctx| {
diff_view.set_diff_session_type(diff_session_type);
diff_view.set_candidate_diffs(diffs, ctx);
});
}
fn generate_ai_identifiers(
&self,
conversation_id: &AIConversationId,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> Option<AIIdentifiers> {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let conversation = history_model.conversation(conversation_id)?;
// Find the `AIAgentExchange` and its corresponding `AIAgentOutput` for this given action.
let (exchange, output) = conversation.all_exchanges().into_iter().find_map(|exchange| {
let output = exchange.output_status.output()?;
let contains_action = output.get().messages.iter().any(|step| {
matches!(step, AIAgentOutputMessage{ message: AIAgentOutputMessageType::Action(AIAgentAction { id, .. }), .. } if id == action_id)
});
contains_action.then_some((exchange, output))
})?;
let server_output_id = output.get().server_output_id.clone();
let model_id = output.get().model_info.as_ref().map(|m| m.model_id.clone());
Some(AIIdentifiers {
client_conversation_id: Some(*conversation_id),
client_exchange_id: Some(exchange.id),
server_output_id,
server_conversation_id: conversation
.server_conversation_token()
.cloned()
.map(Into::into),
model_id,
})
}
}
impl Entity for RequestFileEditsExecutor {
type Event = ();
}
@@ -0,0 +1,162 @@
//! Entity submodel that encapsulates all filesystem access for diff application.
//!
//! The executor holds a [`ModelHandle<ApplyDiffModel>`] and calls
//! [`ApplyDiffModel::apply_diffs`] without knowing whether the session is local
//! or remote. Internally the method resolves the session context and remote
//! client from the model context, then dispatches:
//!
//! - **Local**: calls [`apply_edits`] with a `std::fs`-backed closure.
//! - **Remote**: calls [`apply_edits`] with a [`RemoteServerClient`]-backed closure.
use ai::diff_validation::AIRequestedCodeDiff;
use futures::FutureExt;
use vec1::Vec1;
use warpui::r#async::BoxFuture;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
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`].
pub(crate) struct ApplyDiffModel {
active_session: ModelHandle<ActiveSession>,
}
impl Entity for ApplyDiffModel {
type Event = ();
}
impl ApplyDiffModel {
pub fn new(active_session: ModelHandle<ActiveSession>) -> Self {
Self { active_session }
}
/// Resolves session context and remote client from the model context, then
/// returns a future that applies the edits locally or remotely.
pub fn apply_diffs(
&self,
edits: Vec<FileEdit>,
ai_identifiers: &AIIdentifiers,
passive_diff: bool,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>> {
let session_context = SessionContext::from_session(self.active_session.as_ref(ctx), ctx);
let background_executor = ctx.background_executor();
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 is_remote = session_context.is_remote();
let fut = async move {
if is_remote {
match remote_client {
Some(client) => {
apply_edits(
edits,
&session_context,
&ai_identifiers,
background_executor,
auth_state,
passive_diff,
|path| {
let client = client.clone();
async move { read_remote_file(&client, &path).await }
},
)
.await
}
None => Err(vec1::vec1![
DiffApplicationError::RemoteFileOperationsUnsupported
]),
}
} else {
apply_edits(
edits,
&session_context,
&ai_identifiers,
background_executor,
auth_state,
passive_diff,
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
)
.await
}
};
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
fut.boxed_local()
} else {
fut.boxed()
}
}
}
}
// ── Remote file reading ──────────────────────────────────────────────────────────
/// Per-file byte limit for remote diff application (10 MB).
const MAX_DIFF_READ_BYTES: u32 = 10_000_000;
async fn read_remote_file(
client: &remote_server::client::RemoteServerClient,
path: &str,
) -> FileReadResult {
let request = remote_server::proto::ReadFileContextRequest {
files: vec![remote_server::proto::ReadFileContextFile {
path: path.to_string(),
line_ranges: vec![],
}],
max_file_bytes: Some(MAX_DIFF_READ_BYTES),
max_batch_bytes: None,
};
match client.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
// have line_range_start/end set even though no ranges were
// requested. Detect this and fail explicitly rather than
// applying the diff to partial content.
if fc.line_range_start.is_some() || fc.line_range_end.is_some() {
return FileReadResult::ReadError(format!(
"File exceeds the {MAX_DIFF_READ_BYTES}-byte limit for remote diff \
application and was truncated. The diff cannot be applied safely."
));
}
match fc.content {
Some(remote_server::proto::file_context_proto::Content::TextContent(
content,
)) => FileReadResult::Found(content),
Some(remote_server::proto::file_context_proto::Content::BinaryContent(_)) => {
// apply-diff only works with text files
FileReadResult::ReadError("File is binary".to_string())
}
None => FileReadResult::Found(String::new()),
}
} else if let Some(failed) = response.failed_files.into_iter().next() {
let message = failed
.error
.map(|e| e.message)
.unwrap_or_else(|| "Unknown error".to_string());
if message.contains("not found") || message.contains("Not found") {
FileReadResult::NotFound
} else {
FileReadResult::ReadError(message)
}
} else {
FileReadResult::NotFound
}
}
Err(err) => FileReadResult::ReadError(format!("{err}")),
}
}
@@ -0,0 +1,765 @@
//! 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 ai::diff_validation::{
fuzzy_match_diffs, fuzzy_match_v4a_diffs, AIRequestedCodeDiff, DiffDelta, DiffMatchFailures,
DiffType, ParsedDiff, SearchAndReplace, V4AHunk,
};
use itertools::Itertools;
use vec1::Vec1;
use warpui::r#async::executor::Background;
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,
};
/// Result of reading a file from disk or a remote server.
///
/// This is the common currency between the local (`std::fs`) and remote
/// (`RemoteServerClient`) file-reading paths so that all diff application
/// logic can be shared.
pub(crate) enum FileReadResult {
/// The file was found and its full content is available.
Found(String),
/// The file does not exist.
NotFound,
/// The file could not be read for a reason other than "not found".
ReadError(String),
}
impl From<std::io::Result<String>> for FileReadResult {
fn from(result: std::io::Result<String>) -> Self {
match result {
Ok(content) => FileReadResult::Found(content),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => FileReadResult::NotFound,
Err(err) => FileReadResult::ReadError(format!("{err:#}")),
}
}
}
/// Errors that can occur while applying a diff.
#[derive(Debug)]
pub(crate) enum DiffApplicationError {
/// Some diffs could not be matched against the file content.
UnmatchedDiffs {
file: String,
match_failures: DiffMatchFailures,
},
/// The file that a diff should be applied to did not exist.
MissingFile {
file: String,
},
/// The file could not be read (I/O error, permissions, remote connectivity, etc.).
ReadFailed {
file: String,
// TODO(CODE-353): Display I/O errors to the user, since they may be able to fix them.
#[expect(dead_code)]
message: String,
},
/// A file that was supposed to be new already exists.
AlreadyExists {
file: String,
},
/// The diff contained multiple attempts to create the same file.
MultipleFileCreation {
file: String,
},
/// No diffs could be applied.
EmptyDiff,
MutatedDeletedFile {
file: String,
},
MultipleFileRenames {
file: String,
},
/// File read/write operations are not available on this remote session.
/// This covers both connection-dropped and unsupported SSH session types.
RemoteFileOperationsUnsupported,
}
impl DiffApplicationError {
/// Format this error for inclusion in the agent conversation. The error should help the LLM
/// retry and generate a valid diff.
fn to_conversation_message(&self) -> String {
match self {
DiffApplicationError::UnmatchedDiffs {
file,
match_failures,
} => {
use std::fmt::Write;
let mut message = String::new();
if match_failures.fuzzy_match_failures > 0 {
let _ = write!(message, "Could not apply all diffs to {file}.");
}
if match_failures.noop_deltas > 0 {
if !message.is_empty() {
message.push(' ');
}
let _ = write!(message, "The changes to {file} were already made.");
}
message
}
DiffApplicationError::MissingFile { file } => {
format!("{file} does not exist. Is the path correct?")
}
DiffApplicationError::AlreadyExists { file } => {
format!("Could not create {file} because it already exists.")
}
DiffApplicationError::ReadFailed { file, .. } => {
format!("Could not read {file}")
}
DiffApplicationError::MultipleFileCreation { file } => {
format!("There can only be one attempt to create {file}.")
}
DiffApplicationError::MultipleFileRenames { file } => {
format!("There can only be one attempt to rename {file}.")
}
DiffApplicationError::MutatedDeletedFile { file } => {
format!("Could not mutate a deleted file {file}.")
}
DiffApplicationError::EmptyDiff => "No diffs could be applied.".to_string(),
DiffApplicationError::RemoteFileOperationsUnsupported => {
"The file read/edit tool is not available on this remote session. Try using a different tool.".to_string()
}
}
}
/// Format a list of errors for inclusion in the agent conversation.
pub fn error_for_conversation(errors: &Vec1<DiffApplicationError>) -> String {
if errors.len() == 1 {
errors.first().to_conversation_message()
} else {
errors
.iter()
.format_with("\n", |err, f| {
f(&format_args!("* {}", err.to_conversation_message()))
})
.to_string()
}
}
}
/// Given a list of suggested edits from the server API, parse it into applicable diffs to be shown
/// to the user as a series of code diffs.
///
/// * Search-and-replace diffs are matched to existing files on disk
/// * Created files are expected to not already exist
///
/// Errors are reported as telemetry, and also returned for display.
pub(crate) async fn apply_edits<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
ai_identifiers: &AIIdentifiers,
background_executor: Arc<Background>,
auth_state: Arc<AuthState>,
passive_diff: bool,
read_file: F,
) -> Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let result = apply_edits_internal(edits, session_context, &read_file).await;
// Send telemetry for all diff application errors.
// Count of attempts to edit a file that doesn't exist or create a file that already exists.
let mut invalid_file_count = 0;
for error in result.errors.iter() {
match error {
DiffApplicationError::UnmatchedDiffs { match_failures, .. } => {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::DiffMatchFailed(DiffMatchFailedEvent {
identifiers: ai_identifiers.clone(),
failures: *match_failures,
passive_diff,
}),
background_executor
);
}
DiffApplicationError::MissingFile { .. }
| DiffApplicationError::ReadFailed { .. }
| DiffApplicationError::AlreadyExists { .. }
| DiffApplicationError::MultipleFileCreation { .. }
| DiffApplicationError::MutatedDeletedFile { .. }
| DiffApplicationError::MultipleFileRenames { .. }
| DiffApplicationError::RemoteFileOperationsUnsupported => {
invalid_file_count += 1;
}
DiffApplicationError::EmptyDiff => {}
}
}
if invalid_file_count > 0 {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::DiffInvalidFile(DiffInvalidFileEvent {
count: invalid_file_count,
identifiers: ai_identifiers.clone(),
passive_diff,
}),
background_executor
);
}
// Send telemetry for any warnings, which don't necessarily prevent diff application.
let total_missing_line_numbers: u8 = result
.warnings
.iter()
.map(|warning| match warning {
DiffWarning::MissingLineNumbers { count, .. } => *count,
})
.sum();
if total_missing_line_numbers > 0 {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::MissingLineNumbers(MissingLineNumbersEvent {
identifiers: ai_identifiers.clone(),
count: total_missing_line_numbers,
passive_diff,
}),
background_executor
);
}
match Vec1::try_from_vec(result.errors) {
Ok(errors) => Err(errors),
Err(vec1::Size0Error) => Ok(result.diffs),
}
}
/// Warnings are issues that don't necessarily prevent diff application, but indicate an unexpected
/// response from the LLM.
///
/// For example, we expect the search string in a diff to include line numbers, but can rely on
/// fuzzy matching if they're missing.
#[derive(Debug, Clone)]
pub enum DiffWarning {
/// Search blocks that are missing line numbers.
MissingLineNumbers { count: u8 },
}
#[derive(Default)]
struct DiffResult {
/// All successfully-applied diffs, grouped by file.
diffs: Vec<AIRequestedCodeDiff>,
/// All errors that occurred while applying diffs.
errors: Vec<DiffApplicationError>,
/// All warnings that occurred while applying diffs.
warnings: Vec<DiffWarning>,
}
/// You generally want to use `apply_edits`, however, if you don't want to report telemetry or be as
/// strict, this is available. For example, we use this when debug importing conversations.
async fn apply_edits_internal<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
read_file: &F,
) -> DiffResult
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let mut search_replace_deltas: HashMap<String, Vec<SearchAndReplace>> = HashMap::new();
let mut v4a_deltas: HashMap<String, Vec<V4AHunk>> = HashMap::new();
let mut new_files: HashMap<String, String> = HashMap::new();
let mut deleted_files: HashSet<String> = HashSet::new();
let mut file_renames: HashMap<String, String> = HashMap::new();
let mut result = DiffResult::default();
for edit in edits {
match edit {
FileEdit::Edit(diff) => {
let Some(file_path) = diff.file().cloned() else {
continue;
};
match diff {
ParsedDiff::StrReplaceEdit { .. } => {
let deltas = search_replace_deltas.entry(file_path).or_default();
if let Ok(d) = diff.try_into() {
deltas.push(d);
}
}
ParsedDiff::V4AEdit { hunks, move_to, .. } => {
v4a_deltas
.entry(file_path.clone())
.or_default()
.extend(hunks);
if let Some(move_to) = move_to {
if file_renames.contains_key(&file_path) {
result
.errors
.push(DiffApplicationError::MultipleFileRenames {
file: file_path.clone(),
});
continue;
}
file_renames.insert(file_path, move_to);
}
}
};
}
FileEdit::Create { file, content } => {
let Some(file_path) = file else { continue };
match new_files.entry(file_path) {
Entry::Occupied(entry) => {
result
.errors
.push(DiffApplicationError::MultipleFileCreation {
file: entry.key().clone(),
});
continue;
}
Entry::Vacant(entry) => {
let Some(content) = content else {
continue;
};
entry.insert(content);
}
}
}
FileEdit::Delete { file } => {
// Deleting the same file multiple times is valid, I guess...
let Some(file_path) = file else { continue };
deleted_files.insert(file_path);
}
}
}
let search_replace_files: HashSet<String> = search_replace_deltas.keys().cloned().collect();
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();
for (file_path, deltas) in search_replace_deltas {
// If a file is also being explicitly created/deleted, skip applying edits to avoid
// producing redundant errors (e.g. MissingFile alongside MultipleFileCreation).
if new_file_paths.contains(&file_path) || deleted_file_paths.contains(&file_path) {
continue;
}
apply_search_replace(file_path, deltas, session_context, read_file, &mut result).await;
}
for (file_path, deltas) in v4a_deltas {
if new_file_paths.contains(&file_path) || deleted_file_paths.contains(&file_path) {
continue;
}
let rename_to = file_renames.get(&file_path).cloned();
apply_v4a_update(
file_path,
deltas,
rename_to,
session_context,
read_file,
&mut result,
)
.await;
}
for (file, content) in new_files {
if search_replace_files.contains(&file)
|| v4a_files.contains(&file)
|| file_renames.contains_key(&file)
{
result
.errors
.push(DiffApplicationError::MultipleFileCreation { file });
} else {
apply_create_file(file, content, session_context, read_file, &mut result).await;
}
}
for file in deleted_files {
if new_file_paths.contains(&file)
|| search_replace_files.contains(&file)
|| v4a_files.contains(&file)
|| file_renames.contains_key(&file)
{
result
.errors
.push(DiffApplicationError::MutatedDeletedFile { file });
} else {
apply_delete_file(file, session_context, read_file, &mut result).await;
}
}
result
}
/// Converts a file-creation request into a diff.
async fn apply_create_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(_) => {
safe_warn!(
safe: ("Agent Code tried to create a file that already exists"),
full: ("Agent Code tried to create a file that already exists: {absolute_path:?}")
);
result
.errors
.push(DiffApplicationError::AlreadyExists { file: file_path });
}
FileReadResult::NotFound => {
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::creation(content),
failures: None,
original_content: String::new(),
});
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to check if file exists for Agent Code: {err}"),
full: ("Unable to check if file exists for Agent Code: {absolute_path:?} {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
}
}
}
async fn apply_delete_file<F, Fut>(
file_path: 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();
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::deletion(num_lines),
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,
});
}
}
}
/// Applies a set of search-and-replace diffs to a file.
async fn apply_search_replace<F, Fut>(
file_path: String,
deltas: Vec<SearchAndReplace>,
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::NotFound => {
match deltas.into_iter().exactly_one() {
Ok(SearchAndReplace { search, replace }) => {
if search.is_empty() {
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::creation(replace),
failures: None,
original_content: String::new(),
})
} else {
safe_warn!(
safe: ("Suggested non-empty diff on non-existent file"),
full: ("Suggested non-empty diff on non-existent file: {absolute_path:?}")
);
// A non-empty search block on a non-existent file indicates that the
// LLM likely got the path wrong, and is not trying to create a new file.
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
}
}
Err(err) => {
safe_warn!(
safe: ("Suggested {} diffs on non-existent file", err.len()),
full: ("Suggested {} diffs on non-existent file: {absolute_path:?}", err.len())
);
// Multiple diffs on a non-existent file indicate that the LLM likely got
// the path wrong, and is not trying to create a new file.
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,
});
}
FileReadResult::Found(file_content) => {
safe_debug!(
safe: ("Matching diffs"),
full: ("Matching diffs for: {file_path:?}")
);
let fuzzy_match_diffs = fuzzy_match_diffs(&file_path, &deltas, file_content);
// Add warnings from the failure info - the `DiffMatchFailures` type includes both
// fatal and non-fatal errors.
if let Some(failures) = fuzzy_match_diffs.failures.as_ref() {
if failures.missing_line_numbers > 0 {
result.warnings.push(DiffWarning::MissingLineNumbers {
count: failures.missing_line_numbers,
});
}
}
if fuzzy_match_diffs.warrants_failure() {
if let Some(failures) = fuzzy_match_diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying diff: {failures:?}"),
full: ("Failure(s) applying diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
}
result.diffs.push(fuzzy_match_diffs);
}
}
}
async fn apply_v4a_update<F, Fut>(
file_path: String,
deltas: Vec<V4AHunk>,
rename_to: Option<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(),
);
let file_content = match read_file(absolute_path.clone()).await {
FileReadResult::NotFound => {
safe_warn!(
safe: ("V4A edits requested on non-existent file"),
full: ("V4A edits requested on non-existent file: {absolute_path:?}")
);
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
return;
}
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,
});
return;
}
FileReadResult::Found(content) => content,
};
safe_debug!(
safe: ("Matching V4A diffs"),
full: ("Matching V4A diffs for: {file_path:?}")
);
// Check if we're renaming to an existing file.
let rename_target_content = if let Some(target) = &rename_to {
let target_absolute = host_native_absolute_path(
target,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(target_absolute.clone()).await {
FileReadResult::Found(content) => Some(content),
FileReadResult::NotFound => None,
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read rename target file: {err}"),
full: ("Unable to read rename target file {target_absolute:?}: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: target.clone(),
message: err,
});
return;
}
}
} else {
None
};
if let Some(target_content) = rename_target_content {
// Renaming A to B where B already exists:
// 1. Create deletion for A.
// 2. Replace all of B with A.
// 3. Create update for B that applies the original diff to A.
let rename_target = rename_to.unwrap();
// First, match the V4A diffs against the source file (without rename)
let source_diffs = fuzzy_match_v4a_diffs(&file_path, &deltas, None, file_content.clone());
if source_diffs.warrants_failure() {
if let Some(failures) = source_diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying V4A diff: {failures:?}"),
full: ("Failure(s) applying V4A diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
return;
}
let target_num_lines = target_content.lines().count();
let source_num_lines = file_content.lines().count();
// Reuse the copy that fuzzy_match_v4a_diffs already
// made for its original_content field, so we only
// allocate once instead of cloning file_content again.
let deletion_original_content = source_diffs.original_content;
// Replace all of B's content with A's content.
// Moves file_content — no clone needed.
let mut new_deltas = Vec::new();
let replacement_range = if target_num_lines == 0 {
0..0
} else {
1..(target_num_lines + 1)
};
new_deltas.push(DiffDelta {
replacement_line_range: replacement_range,
insertion: file_content,
});
// Apply the original diff to A.
if let DiffType::Update {
deltas: source_deltas,
..
} = source_diffs.diff_type
{
new_deltas.extend(source_deltas);
}
// Create deletion diff for source file A
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path.clone(),
diff_type: DiffType::deletion(source_num_lines),
failures: None,
original_content: deletion_original_content,
});
result.diffs.push(AIRequestedCodeDiff {
file_name: rename_target,
diff_type: DiffType::update(new_deltas, None),
failures: None,
original_content: target_content,
});
} else {
// Normal case: no rename or rename to non-existent file
let diffs = fuzzy_match_v4a_diffs(&file_path, &deltas, rename_to, file_content);
if diffs.warrants_failure() {
if let Some(failures) = diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying V4A diff: {failures:?}"),
full: ("Failure(s) applying V4A diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
}
result.diffs.push(diffs);
}
}
#[cfg(test)]
#[path = "diff_application_tests.rs"]
mod tests;
@@ -0,0 +1,248 @@
/// Coarse format classification for the edit payload that produced a code diff.
///
/// This distinguishes the legacy search/replace edit format from the structured
/// V4A patch format used by `apply_patch`.
use ai::diff_validation::DiffMatchFailures;
use serde::Serialize;
use serde_json::json;
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use crate::ai::{agent::AIIdentifiers, blocklist::RequestedEditResolution};
/// Telemetry events associated with the `RequestFileEdits` AI agent action.
#[derive(Serialize, Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum RequestFileEditsTelemetryEvent {
EditResolved(EditResolvedEvent),
EditAcceptClicked(EditAcceptClickedEvent),
EditAcceptAndContinueClicked(EditAcceptAndContinueClickedEvent),
DiffMatchFailed(DiffMatchFailedEvent),
DiffInvalidFile(DiffInvalidFileEvent),
EditReceived(EditReceivedEvent),
MissingLineNumbers(MissingLineNumbersEvent),
MalformedFinalLineProxy(MalformedFinalLineProxyEvent),
}
/// Emitted when a user Accepts or Rejects a code diff suggestsion from Agent Mode.
#[derive(Serialize, Debug)]
pub struct EditResolvedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
pub response: RequestedEditResolution,
/// Information about the resolved edit, only set if it is accepted.
pub stats: EditStats,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when a user selects Accept for a code diff suggestion.
#[derive(Serialize, Debug)]
pub struct EditAcceptClickedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when a user selects Accept and start conversation for a code diff suggestion.
#[derive(Serialize, Debug)]
pub struct EditAcceptAndContinueClickedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
}
#[derive(Serialize, Debug)]
pub struct EditStats {
/// Number of files that were edited.
pub files_edited: usize,
/// Number of lines that were added.
pub lines_added: usize,
/// Number of lines that were removed.
pub lines_removed: usize,
}
#[derive(Serialize, Debug)]
pub struct DiffMatchFailedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
#[serde(flatten)]
pub failures: DiffMatchFailures,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Could not find the file(s) given in a code diff.
#[derive(Serialize, Debug)]
pub struct DiffInvalidFileEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
pub count: usize,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when code edits are displayed to the user.
#[derive(Serialize, Debug)]
pub struct EditReceivedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of unique files in the code diff.
pub unique_files: usize,
/// Total number of diffs in the event.
pub diffs: usize,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when search blocks are missing line numbers (non-fatal warning).
#[derive(Serialize, Debug)]
pub struct MissingLineNumbersEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of search blocks missing line numbers.
pub count: u8,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
#[derive(Serialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum RequestFileEditsFormatKind {
/// Legacy search/replace diff format (`edit_files` style).
StrReplace,
/// Structured V4A patch format (`apply_patch` style with Begin/End Patch hunks).
V4A,
/// Both formats were present in the same requested edit payload.
Mixed,
/// The format could not be determined from the payload.
Unknown,
}
/// Emitted when accepted diffs indicate a likely malformed trailing-line condition.
///
/// This signal is emitted when final changed lines intersect the model-proposed terminal changed
/// range and the proposed terminal line matches a malformed-line heuristic.
#[derive(Serialize, Debug)]
pub struct MalformedFinalLineProxyEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of files included in the accepted edit.
pub file_count: usize,
/// Number of files that were edited by the user prior to accepting.
pub edited_file_count: usize,
/// Number of files where:
/// - final changed lines intersect the model-proposed terminal changed range, and
/// - the proposed terminal line matched the malformed-line heuristic.
pub correction_count: usize,
/// Number of `correction_count` detections where `was_edited` was true.
pub edited_correction_count: usize,
/// Number of `correction_count` detections where `was_edited` was false.
pub unedited_correction_count: usize,
/// Coarse source format for the requested edit payload.
pub format_kind: RequestFileEditsFormatKind,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
impl TelemetryEvent for RequestFileEditsTelemetryEvent {
fn name(&self) -> &'static str {
RequestFileEditsTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<serde_json::Value> {
match self {
RequestFileEditsTelemetryEvent::EditResolved(resolved_edit_event) => {
Some(json!(resolved_edit_event))
}
RequestFileEditsTelemetryEvent::EditAcceptClicked(edit_accept_clicked_event) => {
Some(json!(edit_accept_clicked_event))
}
RequestFileEditsTelemetryEvent::EditAcceptAndContinueClicked(
edit_accept_and_continue_clicked_event,
) => Some(json!(edit_accept_and_continue_clicked_event)),
RequestFileEditsTelemetryEvent::DiffMatchFailed(diff_application_error_event) => {
Some(json!(diff_application_error_event))
}
RequestFileEditsTelemetryEvent::DiffInvalidFile(diff_invalid_file_event) => {
Some(json!(diff_invalid_file_event))
}
RequestFileEditsTelemetryEvent::EditReceived(edit_received_event) => {
Some(json!(edit_received_event))
}
RequestFileEditsTelemetryEvent::MissingLineNumbers(missing_line_numbers_event) => {
Some(json!(missing_line_numbers_event))
}
RequestFileEditsTelemetryEvent::MalformedFinalLineProxy(
malformed_final_line_proxy_event,
) => Some(json!(malformed_final_line_proxy_event)),
}
}
fn description(&self) -> &'static str {
RequestFileEditsTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
RequestFileEditsTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
RequestFileEditsTelemetryEventDiscriminants::from(self).contains_ugc()
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl RequestFileEditsTelemetryEventDiscriminants {
pub fn contains_ugc(&self) -> bool {
false
}
}
impl TelemetryEventDesc for RequestFileEditsTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::EditResolved => "AgentMode.Code.SuggestedEditResolved",
Self::EditAcceptClicked => "AgentMode.Code.SuggestedEditAcceptClicked",
Self::EditAcceptAndContinueClicked => {
"AgentMode.Code.SuggestedEditAcceptAndContinueClicked"
}
Self::DiffMatchFailed => "AgentMode.Code.DiffMatchFailed",
Self::DiffInvalidFile => "AgentMode.Code.InvalidFile",
Self::EditReceived => "AgentMode.Code.SuggestedEditReceived",
Self::MissingLineNumbers => "AgentMode.Code.MissingLineNumbers",
Self::MalformedFinalLineProxy => "AgentMode.Code.MalformedFinalLineProxy",
}
}
fn description(&self) -> &'static str {
match self {
Self::EditResolved => "Agent Mode pending code edit suggestion resolved",
Self::EditAcceptClicked => {
"User selected Accept for a code diff suggestion in Agent Mode"
}
Self::EditAcceptAndContinueClicked => {
"User selected Accept and start conversation for a code diff suggestion in Agent Mode"
}
Self::DiffMatchFailed => "Failed to match code diff",
Self::DiffInvalidFile => "File(s) in code diff could not be found",
Self::EditReceived => "Agent Mode suggested a code edit",
Self::MissingLineNumbers => "Code diff was missing line numbers",
Self::MalformedFinalLineProxy => {
"Suggested code diff likely required malformed trailing line correction (heuristic)"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
}
warp_core::register_telemetry_event!(RequestFileEditsTelemetryEvent);
@@ -0,0 +1,390 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use itertools::Itertools;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
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,
};
pub struct SearchCodebaseExecutor {
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
/// Per-action response channels for searches that are still waiting on
/// `GetRelevantFilesController`.
active_searches: HashMap<AIAgentActionId, oneshot::Sender<SearchCodebaseResult>>,
/// Cached repo roots derived during preprocessing so permission checks and execution can agree
/// on which repository the action actually targets.
root_repo_paths: HashMap<AIAgentActionId, PathBuf>,
terminal_view_id: EntityId,
}
impl SearchCodebaseExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
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, .. } => {
let action_id = event.action_id().clone();
let locations = fragments
.iter()
.map(|location| location.into())
.collect_vec();
let current_working_directory = me
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = me.active_session.as_ref(ctx).shell_launch_data(ctx);
ctx.spawn(
async move {
match read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await
{
Ok(result) => {
if !result.missing_files.is_empty() {
let missing_files = result.missing_files.join(", ");
SearchCodebaseResult::Failed {
message: format!(
"These files do not exist: {missing_files}"
),
reason: SearchCodebaseFailureReason::InvalidFilePaths,
}
} else {
SearchCodebaseResult::Success {
files: result.file_contexts,
}
}
}
Err(e) => SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::ClientError,
message: e.to_string(),
},
}
},
move |me, result, _| {
let Some(result_tx) = me.active_searches.remove(&action_id) else {
return;
};
if let Err(e) = result_tx.send(result) {
log::warn!(
"Failed to send search codebase results to receiver {e:?}."
);
}
},
);
}
GetRelevantFilesControllerEvent::Error { action_id } => {
let Some(result_tx) = me.active_searches.remove(action_id) else {
return;
};
if let Err(e) = result_tx.send(SearchCodebaseResult::Failed {
message: "The search failed. Try another way to locate the relevant files."
.to_owned(),
reason: SearchCodebaseFailureReason::GetRelevantFilesError,
}) {
log::warn!("Failed to send search codebase results to receiver {e:?}.");
}
}
}
});
Self {
active_session,
get_relevant_files_controller,
active_searches: HashMap::new(),
root_repo_paths: HashMap::new(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::SearchCodebase(..),
..
},
conversation_id,
} = input
else {
return false;
};
self.root_repo_paths.get(id).is_none_or(|root_repo_path| {
// If we have access to read the repo, we can auto-execute the search.
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![root_repo_path.to_owned()],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
})
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
id,
action:
AIAgentActionType::SearchCodebase(SearchCodebaseRequest {
query,
partial_paths,
codebase_path,
}),
..
} = action
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 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 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(
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)
},
),
},
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()
}
GetRelevantFilesError::CreateFailed => {
"Relevant file search in the current directory is not available".to_owned()
}
GetRelevantFilesError::Missing => {
"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,
},
))
}
}
}
pub fn root_repo_for_action(&self, id: &AIAgentActionId) -> Option<&Path> {
self.root_repo_paths.get(id).map(|path| path.as_path())
}
pub(super) fn cancel_execution(
&mut self,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) {
// Drop the waiting sender first so any late completion from the controller becomes a no-op.
self.active_searches.remove(action_id);
self.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.cancel_request_for_action(action_id, ctx)
});
}
fn get_root_repo_path_for_request(
&self,
request: &SearchCodebaseRequest,
app: &AppContext,
) -> Option<PathBuf> {
let SearchCodebaseRequest { codebase_path, .. } = request;
let codebase_path = codebase_path.as_deref().map(PathBuf::from);
let Some(pwd) = self
.active_session
.as_ref(app)
.current_working_directory()
.map(PathBuf::from)
else {
// This should never really happen, since we should always have a pwd.
log::warn!("No pwd found for search codebase request");
return None;
};
let search_dir = if FeatureFlag::CrossRepoContext.is_enabled() {
match codebase_path {
Some(codebase_path) if codebase_path == Path::new(".") => pwd,
Some(codebase_path) => codebase_path,
None => pwd,
}
} else {
pwd
};
self.get_relevant_files_controller
.as_ref(app)
.root_directory_for_search(&search_dir, app)
}
/// In the preprocessing step, we determine the root of the repo path for the codebase to be
/// searched, and cache it. This is used downstream to render UI thats derived from the root
/// repo path, which isn't really trivially computable from the `action` itself.
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let AIAgentAction {
id,
action: AIAgentActionType::SearchCodebase(request),
..
} = input.action
else {
log::error!("Expected a SearchCodebase action when preprocessing action");
return futures::future::ready(()).boxed();
};
if let Some(root_repo_path) = self.get_root_repo_path_for_request(request, ctx) {
self.root_repo_paths.insert(id.clone(), root_repo_path);
}
futures::future::ready(()).boxed()
}
}
impl Entity for SearchCodebaseExecutor {
type Event = ();
}
@@ -0,0 +1,147 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
};
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::ServerApiProvider;
use warp_core::features::FeatureFlag;
use warp_core::send_telemetry_from_ctx;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct SendMessageToAgentExecutor;
impl SendMessageToAgentExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> AnyActionExecution {
let AIAgentAction {
action:
AIAgentActionType::SendMessageToAgent {
addresses,
subject,
message,
},
..
} = input.action
else {
return ActionExecution::<()>::InvalidAction.into();
};
let conversation_id = input.conversation_id;
let addresses = addresses.clone();
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();
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),
};
ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(result)).into()
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Default for SendMessageToAgentExecutor {
fn default() -> Self {
Self::new()
}
}
impl Entity for SendMessageToAgentExecutor {
type Event = ();
}
@@ -0,0 +1,895 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::{select, FutureExt};
use futures_lite::pin;
use itertools::Itertools;
use parking_lot::FairMutex;
use warp_core::command::ExitCode;
use warp_core::execution_mode::AppExecutionMode;
use warp_util::path::ShellFamily;
use warpui::r#async::{Spawnable, Timer};
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionType, AIAgentPtyWriteMode, ReadShellCommandOutputResult,
RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::permissions::CommandExecutionPermission;
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::WriteToPtyPermission;
use crate::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::{
formatted_terminal_contents_for_input, Block, BlockId, CURSOR_MARKER,
};
use crate::terminal::shell::ShellType;
use crate::{
ai::agent::AIAgentActionResultType,
terminal::{
model::session::active_session::ActiveSession,
model_events::{ModelEvent, ModelEventDispatcher},
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<()>>,
/// Senders used by the `Check now` affordance to force a long-running shell command's
/// pending poll future to resolve immediately with a fresh snapshot, bypassing the
/// agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
control_handback_sender: Option<oneshot::Sender<()>>,
}
impl ShellCommandExecutor {
pub const MAX_WAIT_DURATION: Duration = Duration::from_secs(2);
/// Maximum delay we will honor for any agent-requested wait. Applies both
/// to finite `ShellCommandDelay::Duration` requests and to
/// `ShellCommandDelay::OnCompletion`, which would otherwise wait indefinitely.
pub const MAX_AGENT_DELAY_DURATION: Duration = Duration::from_secs(120);
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, Self::handle_terminal_model_event);
Self {
active_session,
terminal_model,
block_finished_senders: HashMap::new(),
force_refresh_senders: HashMap::new(),
terminal_view_id,
control_handback_sender: None,
}
}
fn handle_terminal_model_event(&mut self, event: &ModelEvent, _ctx: &mut ModelContext<Self>) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
if let Some(block) = block_selector.get_block(&model) {
if block.is_command_finished() {
if let Err(e) = block_finished_tx.send(()) {
log::warn!(
"Failed to notify block completion for running requested command: {e:?}"
)
}
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
}
}
}
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let blocklist_permissions = BlocklistAIPermissions::as_ref(ctx);
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
is_read_only,
is_risky,
..
} => {
let Some(escape_char) = self
.active_session
.as_ref(ctx)
.shell_type(ctx)
.map(|s| ShellFamily::from(s).escape_char())
else {
return false;
};
let autoexecution_permission = blocklist_permissions.can_autoexecute_command(
&input.conversation_id,
command,
escape_char,
is_read_only.unwrap_or(false),
*is_risky,
Some(self.terminal_view_id),
ctx,
);
if let CommandExecutionPermission::Allowed(reason) = autoexecution_permission {
send_telemetry_from_ctx!(
TelemetryEvent::AutoexecutedAgentModeRequestedCommand { reason },
ctx
);
} else if let CommandExecutionPermission::Denied(reason) = autoexecution_permission
{
if AppExecutionMode::as_ref(ctx).is_autonomous() {
log::warn!(
"Command denied during autonomous execution, reason: {reason:?}"
);
}
}
autoexecution_permission.is_allowed()
}
AIAgentActionType::WriteToLongRunningShellCommand { block_id, .. } => {
let terminal_model = self.terminal_model.lock();
let block = terminal_model.block_list().block_with_id(block_id);
if block.is_none_or(|block| block.finished()) {
// If the block is already finished, allow auto-execution - the finished output
// will be returned.
true
} else {
let should_autoexecute = match blocklist_permissions.can_write_to_pty(
&input.conversation_id,
Some(self.terminal_view_id),
ctx,
) {
WriteToPtyPermission::AlwaysAllow => true,
WriteToPtyPermission::AskOnFirstWrite => terminal_model
.block_list()
.active_block()
.has_agent_written_to_block(),
_ => false,
};
if should_autoexecute {
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentActionExecuted {
conversation_id: input.conversation_id,
block_id: block_id.clone(),
is_autoexecuted: true,
},
ctx
);
}
should_autoexecute
}
}
AIAgentActionType::ReadShellCommandOutput { .. } => true,
AIAgentActionType::TransferShellCommandControlToUser { .. } => false,
_ => false,
}
}
/// Decorate the command so that we can turn off pager.
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
match self.active_session.as_ref(ctx).shell_type(ctx) {
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
// avoid cases with aliases.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
// gets evaluated first.
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
// For powershell, we use Out-Host to send paged output to the
// console. Add a backslash to avoid executing an alias.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
// If we can't determine a shell type, run command as it is.
None => command.clone(),
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let model = self.terminal_model.lock();
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle();
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
uses_pager,
wait_until_completion,
..
} => {
if model
.block_list()
.active_block()
.is_active_and_long_running()
{
// If there is an active block, we can't execute another command.
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.
let decorated_command =
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion {
self.turn_off_pager_for_command(command, ctx)
} else {
command.clone()
};
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(),
command: decorated_command,
});
let block_selector = BlockSelector::RequestedCommandId(action_id.clone());
let command = command.clone();
drop(model);
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), None),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_requested_command(command, result)
},
)
}
AIAgentActionType::WriteToLongRunningShellCommand {
block_id,
input,
mode,
} => {
let Some(block) = model.block_list().block_with_id(block_id) else {
return ActionExecution::Sync(
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Error(
ShellCommandError::BlockNotFound,
),
),
);
};
if block.finished() {
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
return ActionExecution::Sync(
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id: block.id().clone(),
output,
exit_code,
},
),
);
}
// Drop immutable borrow.
drop(model);
let mut model = self.terminal_model.lock();
if let Some(block) = model.block_list_mut().mut_block_from_id(block_id) {
block.mark_agent_written_to_block();
}
drop(model);
ctx.emit(ShellCommandExecutorEvent::WriteToPty {
input: input.clone(),
mode: *mode,
});
let block_selector = BlockSelector::Id(block_id.clone());
ActionExecution::new_async(
self.action_result_future(
block_selector.clone(),
Some(ShellCommandDelay::Duration(Duration::from_millis(200))),
),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_write_to_long_running_shell_command(result)
},
)
}
AIAgentActionType::ReadShellCommandOutput { block_id, delay } => {
let Some(block) = model.block_list().block_with_id(block_id) else {
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
));
};
if block.finished() {
let command = block.command_with_secrets_unobfuscated(false);
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id: block_id.clone(),
output,
exit_code,
},
));
}
drop(model);
let block_selector = BlockSelector::Id(block_id.clone());
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), delay.clone()),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_read_shell_command_output(command.clone(), result)
},
)
}
AIAgentActionType::TransferShellCommandControlToUser { reason } => {
let active_block = model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return ActionExecution::Sync(
AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Error(
ShellCommandError::BlockNotFound,
),
),
);
}
let block_id = active_block.id().clone();
drop(model);
// Emit event to transfer control to user.
ctx.emit(ShellCommandExecutorEvent::TransferControlToUser {
action_id: action_id.clone(),
reason: reason.clone(),
});
// Create a channel to wait for control handback.
let (handback_tx, handback_rx) = oneshot::channel();
self.control_handback_sender = Some(handback_tx);
let block_selector = BlockSelector::Id(block_id.clone());
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
let terminal_model = self.terminal_model.clone();
let block_id = block_id.clone();
async move {
pin!(handback_rx);
pin!(block_finished_rx);
// Wait for either control handback or block completion.
let transfer_result = select! {
val = handback_rx => match val {
Ok(_) => TransferControlResult::ControlHandedBack,
Err(_) => TransferControlResult::Cancelled,
},
val = block_finished_rx => match val {
Ok(_) => TransferControlResult::BlockFinished,
Err(_) => TransferControlResult::Cancelled,
},
};
// Convert to ActionResult
let model = terminal_model.lock();
match transfer_result {
TransferControlResult::ControlHandedBack
| TransferControlResult::BlockFinished => {
match model.block_list().block_with_id(&block_id) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_preempted: false,
}
}
}
None => ActionResult::BlockNotFound,
}
}
TransferControlResult::Cancelled => ActionResult::Cancelled,
}
}
};
ActionExecution::new_async(transfer_future, move |result, ctx| {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.control_handback_sender = None;
});
}
action_result_for_transfer_shell_command_control_to_user(result)
})
}
_ => ActionExecution::InvalidAction,
}
}
/// Called when user hands control back to agent after TransferShellCommandControlToUser.
pub fn notify_control_handed_back(&mut self) {
if let Some(sender) = self.control_handback_sender.take() {
let _ = sender.send(());
}
}
/// Produces a future which resolves when the action is complete and
/// we have a result to send to the agent.
fn action_result_future(
&mut self,
block_selector: BlockSelector,
delay: Option<ShellCommandDelay>,
) -> impl Spawnable<Output = ActionResult> {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
// Create a channel so the `Check now` affordance can short-circuit the timeout
// and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
#[derive(Debug, Clone, Copy)]
enum WakeReason {
BlockFinished,
Timeout,
/// User clicked `Check now` in the warping indicator, short-circuiting
/// the agent-set poll timer. Treated as a preemption so the server does
/// not interpret the early snapshot as a completion.
ForceRefresh,
}
async move {
// If we support long-running commands, set up a timeout after which we'll
// treat the command as long-running and give the agent a snapshot of the
// current state. Otherwise, we'll wait indefinitely for the command to
// finish executing.
let mut timeout = match delay {
Some(ShellCommandDelay::Duration(duration)) => {
// Enforce a maximum allowed delay that the agent may request, never waiting longer than MAX_AGENT_DELAY_DURATION.
// If the requested duration exceeds this cap, we'll still behave as if the agent may expect a running command,
// so there's no need to signal preemption (the agent already anticipates an incomplete command state).
Timer::after(duration.min(Self::MAX_AGENT_DELAY_DURATION))
}
Some(ShellCommandDelay::OnCompletion) => {
Timer::after(Self::MAX_AGENT_DELAY_DURATION)
}
None => Timer::after(Self::MAX_WAIT_DURATION),
}
.fuse();
pin!(block_metadata_received_rx);
pin!(force_refresh_rx);
let wake_reason = select! {
val = block_metadata_received_rx => match val {
Ok(_) => WakeReason::BlockFinished,
Err(_) => return ActionResult::Cancelled,
},
val = force_refresh_rx => match val {
// User asked the agent to check now; fall through to the snapshot
// code path below. Treated as a preemption (snapshot arrives before
// the agent's own timer would have fired).
Ok(_) => WakeReason::ForceRefresh,
// Sender was dropped (e.g. because the executor is being torn down).
Err(_) => return ActionResult::Cancelled,
},
_ = timeout => WakeReason::Timeout,
};
// Mark the snapshot as preempted if woken early, allowing the server to distinguish
// true completion from a forced client poll (`ForceRefresh`) or a timeout during `on_completion`.
let is_preempted = matches!(wake_reason, WakeReason::ForceRefresh)
|| matches!(
(&wake_reason, &delay),
(WakeReason::Timeout, Some(ShellCommandDelay::OnCompletion))
);
// At this point, we've either received block metadata or we've timed out.
// Check the current state of the block and produce a result accordingly.
let model = terminal_model.lock();
let result = match block_selector.get_block(&model) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
// TODO(vorporeal): This is probably too large.
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_preempted,
}
}
}
None => ActionResult::BlockNotFound,
};
result
}
}
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return;
}
let selector = if active_block
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
} else {
BlockSelector::Id(active_block.id().clone())
};
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
/// Force any in-flight poll for the given long-running command block to resolve
/// immediately with a fresh snapshot, bypassing the agent-set timeout.
///
/// Called by the `Check now` affordance in the warping indicator. No-ops if there
/// is no matching in-flight poll (e.g. because the block already finished or the
/// agent has transferred control to the user).
pub fn force_refresh_block(&mut self, block_id: &BlockId) {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
// at a time.
let matching_selector = self
.force_refresh_senders
.keys()
.find(|selector| {
selector
.get_block(&terminal_model)
.is_some_and(|block| block.id() == block_id)
})
.cloned();
drop(terminal_model);
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
let _ = sender.send(());
}
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector {
Id(BlockId),
RequestedCommandId(AIAgentActionId),
}
impl BlockSelector {
fn get_block<'a>(&self, model: &'a TerminalModel) -> Option<&'a Block> {
match self {
BlockSelector::Id(block_id) => model.block_list().block_with_id(block_id),
BlockSelector::RequestedCommandId(requested_command_id) => model
.block_list()
.block_for_ai_action_id(requested_command_id),
}
}
}
/// Returns the result from executing a requested command.
fn action_result_for_requested_command(
command: String,
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
command,
block_id,
output,
exit_code,
}),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
..
} => AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot {
command,
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
},
),
ActionResult::BlockNotFound | ActionResult::Cancelled => {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution,
)
}
}
}
/// Returns the result from writing to a long-running shell command.
fn action_result_for_write_to_long_running_shell_command(
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot {
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Cancelled,
),
ActionResult::BlockNotFound => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound),
),
}
}
/// Returns the result from reading shell command output.
fn action_result_for_read_shell_command_output(
command: String,
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
output,
exit_code,
block_id,
} => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
command,
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => {
AIAgentActionResultType::ReadShellCommandOutput(ReadShellCommandOutputResult::Cancelled)
}
ActionResult::BlockNotFound => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
),
}
}
/// Returns the result from transferring shell command control to user.
fn action_result_for_transfer_shell_command_control_to_user(
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::CommandFinished {
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot {
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Cancelled,
),
ActionResult::BlockNotFound => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Error(ShellCommandError::BlockNotFound),
),
}
}
#[derive(Debug, Clone)]
pub enum ShellCommandExecutorEvent {
ExecuteCommand {
action_id: AIAgentActionId,
command: String,
},
WriteToPty {
input: Bytes,
mode: AIAgentPtyWriteMode,
},
CancelExecution,
/// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser {
action_id: AIAgentActionId,
reason: String,
},
}
impl Entity for ShellCommandExecutor {
type Event = ShellCommandExecutorEvent;
}
/// Result from waiting for control transfer.
#[derive(Debug, Clone)]
enum TransferControlResult {
ControlHandedBack,
BlockFinished,
Cancelled,
}
/// The possible results of taking an action.
#[derive(Debug, Clone)]
enum ActionResult {
CommandFinished {
block_id: BlockId,
output: String,
exit_code: ExitCode,
},
LongRunningCommandSnapshot {
block_id: BlockId,
grid_contents: String,
cursor: &'static str,
is_alt_screen_active: bool,
is_preempted: bool,
},
Cancelled,
BlockNotFound,
}
@@ -0,0 +1,438 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::orchestration_event_poller::OrchestrationEventPoller;
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use warp_cli::agent::Harness;
use warp_core::features::FeatureFlag;
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 },
/// An error occurred while starting the agent.
Error(String),
}
fn invalid_local_child_harness_error(harness_type: &str) -> String {
let harness_name = harness_type.trim();
if harness_name.is_empty() {
"Local child harness type is missing.".to_string()
} else {
format!("Unsupported local child harness '{harness_name}'.")
}
}
/// Groups the data for a single StartAgent invocation as it flows from the
/// executor through the terminal view and pane group into the controller.
#[derive(Clone)]
pub struct StartAgentRequest {
pub name: String,
pub prompt: String,
pub execution_mode: StartAgentExecutionMode,
pub lifecycle_subscription: Option<Vec<LifecycleEventType>>,
pub parent_conversation_id: AIConversationId,
pub parent_run_id: Option<String>,
}
/// Tracks a single in-flight StartAgent action. At most one can be pending at
/// a time because StartAgent actions execute serially (RunningActionPhase::Serial).
struct PendingStartAgent {
parent_conversation_id: AIConversationId,
/// Set when `StartedNewConversation` fires for a conversation whose
/// `parent_conversation_id` matches.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentDecision>,
}
pub struct StartAgentExecutor {
/// The currently pending StartAgent action, if any.
pending: Option<PendingStartAgent>,
}
impl StartAgentExecutor {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self { pending: None }
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
BlocklistAIHistoryEvent::StartedNewConversation {
new_conversation_id,
..
} => {
let Some(pending) = self.pending.as_mut() else {
return;
};
if pending.child_conversation_id.is_some() {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(new_conversation_id) else {
return;
};
if conversation.parent_conversation_id() == Some(pending.parent_conversation_id) {
pending.child_conversation_id = Some(*new_conversation_id);
}
}
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
return;
}
let pending = self.pending.take().unwrap();
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.orchestration_agent_id());
match agent_id {
Some(id) => {
let _ = pending.sender.try_send(StartAgentDecision::Started {
agent_id: id.clone(),
});
if FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventPoller::handle(ctx).update(ctx, |poller, ctx| {
poller.register_watched_run_id(
pending.parent_conversation_id,
id,
ctx,
);
});
} else {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_started(*conversation_id, ctx);
});
}
}
None => {
log::error!(
"ConversationServerTokenAssigned fired but no agent identifier for \
{conversation_id:?}"
);
let _ = pending.sender.try_send(StartAgentDecision::Error(
"Server did not assign an agent identifier".to_string(),
));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"missing_agent_id".to_string(),
"Server did not assign an agent identifier".to_string(),
ctx,
);
});
}
}
}
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(conversation_id) else {
return;
};
let error_msg = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message(),
);
if let Some(error_msg) = error_msg {
let pending = self.pending.take().unwrap();
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,
);
});
}
}
}
BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::ReassignedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } => {}
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// TODO(QUALITY-342): this should be a setting
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action:
AIAgentActionType::StartAgent {
version,
name,
prompt,
execution_mode,
lifecycle_subscription,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
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)
}
StartAgentExecutionMode::Local {
harness_type: Some(harness_type),
} => {
let Some(harness) = Harness::parse_local_child_harness(&harness_type) else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: invalid_local_child_harness_error(&harness_type),
version,
},
));
};
if !FeatureFlag::OrchestrationV2.is_enabled() {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Local harness child agents require orchestration v2."
.to_string(),
version,
},
));
}
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 harness child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Local {
harness_type: Some(harness.to_string()),
},
Some(parent_run_id),
)
}
StartAgentExecutionMode::Remote {
environment_id,
skill_references,
model_id,
computer_use_enabled,
worker_host,
harness_type,
title,
} => {
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);
if Harness::parse_orchestration_harness(&harness_type) == Some(Harness::OpenCode) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Remote child agents do not support the opencode harness yet."
.to_string(),
version,
},
));
}
// An empty environment_id is allowed and means the child will be spawned with an
// empty environment (no preconfigured repositories, secrets, or integrations).
// Callers are discouraged from relying on this, but we intentionally do not reject
// it here so that agent authors can opt into running without an environment.
if environment_id.trim().is_empty() {
log::warn!(
"Starting remote child agent with empty environment_id; the child will run \
with an empty environment."
);
}
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: "Remote child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Remote {
environment_id,
skill_references,
model_id,
computer_use_enabled,
worker_host,
harness_type,
title,
},
Some(parent_run_id),
)
}
};
let (sender, receiver) = async_channel::bounded(1);
self.pending = Some(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,
}));
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
match result {
Ok(StartAgentDecision::Started { agent_id }) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id,
version,
})
}
Ok(StartAgentDecision::Error(error)) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
}
Err(_) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Cancelled { version })
}
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
fn start_agent_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
match status {
ConversationStatus::Error => Some(
error_message
.filter(|message| !message.trim().is_empty())
.unwrap_or("Child agent failed to initialize")
.to_string(),
),
ConversationStatus::Cancelled => {
Some("Child agent was cancelled before initialization".to_string())
}
ConversationStatus::Blocked { blocked_action } => {
let blocked_action = blocked_action.trim();
Some(if blocked_action.is_empty() {
"Child agent startup was blocked before initialization".to_string()
} else {
blocked_action.to_string()
})
}
ConversationStatus::InProgress | ConversationStatus::Success => None,
}
}
impl Entity for StartAgentExecutor {
type Event = StartAgentExecutorEvent;
}
pub enum StartAgentExecutorEvent {
CreateAgent(StartAgentRequest),
}
#[cfg(test)]
#[path = "start_agent_tests.rs"]
mod tests;
@@ -0,0 +1,326 @@
use super::*;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use ai::agent::action_result::StartAgentVersion;
use warp_core::features::FeatureFlag;
use warpui::{App, EntityId};
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
) -> 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(),
execution_mode,
lifecycle_subscription: None,
},
task_id: TaskId::new("start-agent-task".to_string()),
requires_result: false,
}
}
#[test]
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
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::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,
ctx,
)
});
executor.read(&app, |executor, _| {
assert_eq!(
executor
.pending
.as_ref()
.and_then(|pending| pending.child_conversation_id),
Some(child_conversation_id)
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Blocked {
blocked_action:
"GitHub authentication required before starting the child agent."
.to_string(),
},
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
== "GitHub authentication required before starting the child agent."
&& version == StartAgentVersion::V1
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_none());
});
});
}
#[test]
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
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::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,
ctx,
)
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error_message(
terminal_view_id,
child_conversation_id,
ConversationStatus::Error,
Some("Failed to resolve child agent skills: review-comments".to_string()),
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 == "Failed to resolve child agent skills: review-comments"
&& version == StartAgentVersion::V1
));
});
}
#[test]
fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
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, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("claude".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 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
});
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
));
});
}
#[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)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("claude".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 execute_returns_error_when_remote_opencode_harness_is_requested() {
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::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![],
model_id: String::new(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "opencode".to_string(),
title: String::new(),
},
);
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 == "Remote child agents do not support the opencode harness yet."
&& version == StartAgentVersion::V2
));
});
}
@@ -0,0 +1,95 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext};
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.
pub enum NewConversationDecision {
Accept,
Reject,
}
pub struct SuggestNewConversationExecutor {
suggest_new_conversation_result_rx: (
async_channel::Sender<NewConversationDecision>,
async_channel::Receiver<NewConversationDecision>,
),
}
impl SuggestNewConversationExecutor {
pub fn new() -> Self {
Self {
suggest_new_conversation_result_rx: async_channel::unbounded(),
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
false
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::SuggestNewConversation { message_id },
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let message_id = message_id.clone();
let receiver = self.suggest_new_conversation_result_rx.clone().1;
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
match result {
Ok(NewConversationDecision::Accept) => {
AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Accepted { message_id },
)
}
Ok(NewConversationDecision::Reject) => {
AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Rejected,
)
}
Err(_) => AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
),
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete_suggest_new_conversation_action(&self, decision: NewConversationDecision) {
let _ = self.suggest_new_conversation_result_rx.0.try_send(decision);
}
}
impl Default for SuggestNewConversationExecutor {
fn default() -> Self {
Self::new()
}
}
impl Entity for SuggestNewConversationExecutor {
type Event = ();
}
@@ -0,0 +1,107 @@
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use warp_core::features::FeatureFlag;
use warpui::{Entity, ModelContext};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionType,
SuggestPromptRequest, SuggestPromptResult,
},
blocklist::action_model::execute::{
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
},
},
AIAgentActionResultType,
};
pub struct PromptSuggestionExecutor {
suggest_prompt_result_tx: Option<oneshot::Sender<SuggestPromptResult>>,
}
impl Default for PromptSuggestionExecutor {
fn default() -> Self {
Self::new()
}
}
impl PromptSuggestionExecutor {
pub fn new() -> Self {
Self {
suggest_prompt_result_tx: None,
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::SuggestPrompt(request),
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
if FeatureFlag::PromptSuggestionsViaMAA.is_enabled() {
if let SuggestPromptRequest::PromptSuggestion { prompt, label } = request {
ctx.emit(PromptSuggestionExecutorEvent::NewPromptSuggestion {
prompt: prompt.clone(),
label: label.clone(),
conversation_id: input.conversation_id,
action_id: input.action.id.clone(),
});
}
}
let (result_tx, result_rx) = oneshot::channel();
self.suggest_prompt_result_tx = Some(result_tx);
ActionExecution::new_async(result_rx, |result, _ctx| match result {
Ok(SuggestPromptResult::Accepted { query }) => {
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Accepted { query })
}
Ok(SuggestPromptResult::Cancelled) | Err(_) => {
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Cancelled)
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete_suggest_prompt_action(&mut self, result: SuggestPromptResult) {
if let Some(sender) = self.suggest_prompt_result_tx.take() {
let _ = sender.send(result);
}
}
}
impl Entity for PromptSuggestionExecutor {
type Event = PromptSuggestionExecutorEvent;
}
#[derive(Debug)]
pub enum PromptSuggestionExecutorEvent {
NewPromptSuggestion {
prompt: String,
label: Option<String>,
conversation_id: AIConversationId,
action_id: AIAgentActionId,
},
}
@@ -0,0 +1,186 @@
#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
#[cfg(test)]
#[path = "upload_artifact_tests.rs"]
mod tests;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
use crate::terminal::model::session::active_session::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use crate::{
ai::{
agent::{AIAgentAction, AIAgentActionResultType, AIAgentActionType, UploadArtifactResult},
agent_sdk::artifact_upload::{FileArtifactUploadRequest, FileArtifactUploader},
blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions},
paths::host_native_absolute_path,
},
server::server_api::ServerApiProvider,
};
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct UploadArtifactExecutor {
#[cfg_attr(target_family = "wasm", allow(dead_code))]
active_session: ModelHandle<ActiveSession>,
#[cfg_attr(target_family = "wasm", allow(dead_code))]
terminal_view_id: EntityId,
}
impl UploadArtifactExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::UploadArtifact(request),
..
},
conversation_id,
} = input
else {
return false;
};
let resolved_path = self.resolve_path(&request.file_path, ctx);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![resolved_path],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> AnyActionExecution {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction.into()
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
action: AIAgentActionType::UploadArtifact(request),
..
} = action
else {
return ActionExecution::<()>::InvalidAction.into();
};
let resolved_path = self.resolve_path(&request.file_path, ctx);
let server_conversation_token = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| conversation.server_conversation_token())
.cloned();
let Some(server_conversation_token) = server_conversation_token else {
return ActionExecution::<()>::Sync(AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(
"Current conversation has not been synced to the server yet".to_string(),
),
))
.into();
};
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(conversation_id, [resolved_path.clone()]);
});
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_api = ServerApiProvider::as_ref(ctx).get();
let description = request.description.clone();
ActionExecution::new_async(
async move {
let uploader = FileArtifactUploader::new(ai_client, server_api);
let request = FileArtifactUploadRequest {
path: resolved_path,
run_id: None,
conversation_id: Some(server_conversation_token),
description,
};
let association = uploader.resolve_upload_association(&request).await?;
uploader.upload_with_association(request, association).await
},
|result, _ctx| match result {
Ok(upload) => {
AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Success {
artifact_uid: upload.artifact.artifact_uid,
filepath: Some(upload.artifact.filepath),
mime_type: upload.artifact.mime_type,
description: upload.artifact.description,
size_bytes: upload.size_bytes,
})
}
Err(err) => AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(err.to_string()),
),
},
)
.into()
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
#[cfg(not(target_family = "wasm"))]
fn resolve_path(&self, file_path: &str, ctx: &ModelContext<Self>) -> PathBuf {
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
PathBuf::from(host_native_absolute_path(
file_path,
&shell,
&current_working_directory,
))
}
}
impl Entity for UploadArtifactExecutor {
type Event = ();
}
@@ -0,0 +1,234 @@
use std::fs;
use std::path::{Path, PathBuf};
use async_channel::unbounded;
use warpui::{App, EntityId, ModelHandle};
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::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::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::{SessionId, SessionInfo, Sessions};
use crate::terminal::model::terminal_model::BlockIndex;
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::LaunchMode;
use super::*;
fn build_upload_artifact_action(file_path: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("upload-artifact-action".to_string()),
action: AIAgentActionType::UploadArtifact(UploadArtifactRequest {
file_path: file_path.to_string(),
description: Some("Upload the generated report".to_string()),
}),
task_id: TaskId::new("upload-artifact-task".to_string()),
requires_result: false,
}
}
fn initialize_upload_artifact_test(
app: &mut App,
terminal_view_id: EntityId,
current_working_directory: &Path,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<ActiveSession>,
) {
initialize_settings_for_tests(app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
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(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
app.add_singleton_model(BlocklistAIPermissions::new);
profiles.update(app, |profiles, ctx| {
if let Some(profile_id) = profiles.create_profile(ctx) {
profiles.set_read_files(profile_id, &ActionPermission::AlwaysAsk, ctx);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
}
});
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_, 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 session_id = SessionId::from(7);
sessions.update(app, |sessions, _ctx| {
let mut session_info = SessionInfo::new_for_test().with_id(session_id);
session_info.launch_data = Some(test_shell_launch_data());
sessions.register_session_for_test(session_info);
});
model_event_dispatcher.update(app, |model_event_dispatcher, ctx| {
model_event_dispatcher.set_active_session_id(session_id);
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(
Some(session_id),
Some(current_working_directory.display().to_string()),
),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
(history, active_session)
}
fn test_shell_launch_data() -> ShellLaunchData {
#[cfg(unix)]
{
ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/bash"),
shell_type: ShellType::Bash,
}
}
#[cfg(windows)]
{
ShellLaunchData::Executable {
executable_path: PathBuf::from(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
),
shell_type: ShellType::PowerShell,
}
}
}
#[test]
fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
let artifact_path = cwd.join("reports/report.txt");
fs::create_dir_all(artifact_path.parent().unwrap()).unwrap();
fs::write(&artifact_path, "artifact contents").unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, active_session) =
initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
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)
});
let action = build_upload_artifact_action("reports/report.txt");
let should_autoexecute_before = executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(!should_autoexecute_before);
app.update(|ctx| {
BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, _ctx| {
permissions
.add_temporary_file_read_permissions(conversation_id, [artifact_path.clone()]);
});
});
let should_autoexecute_after = executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(should_autoexecute_after);
});
}
#[test]
fn execute_returns_error_when_conversation_has_not_synced_to_server() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
let artifact_path = cwd.join("report.txt");
fs::write(&artifact_path, "artifact contents").unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, active_session) =
initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
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)
});
let action = build_upload_artifact_action(&artifact_path.display().to_string());
let execution = executor.update(&mut app, |executor, ctx| {
executor.execute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(message),
)) if message == "Current conversation has not been synced to the server yet"
));
});
}
#[test]
fn resolve_path_uses_active_session_working_directory_for_relative_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (_, active_session) = initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let resolved_path = executor.update(&mut app, |executor, ctx| {
executor.resolve_path("reports/out.txt", ctx)
});
assert_eq!(resolved_path, cwd.join("reports/out.txt"));
});
}
@@ -0,0 +1,71 @@
use ai::agent::action_result::AIAgentActionResultType;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext};
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct UseComputerExecutor;
impl UseComputerExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::UseComputer(_) = &action.action else {
return false;
};
// We unconditionally return true here because this action is only executed by
// the computer use subagent, which cannot begin without the user approving it via
// a `RequestComputerUse` action, and the approval extends to all computer use
// actions within that computer use subagent.
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::UseComputer(request) = &action.action else {
return ActionExecution::InvalidAction;
};
let actions = request.actions.clone();
let screenshot_params = request.screenshot_params;
ActionExecution::new_async(
async move {
let mut actor = computer_use::create_actor();
match actor
.perform_actions(&actions, computer_use::Options { screenshot_params })
.await
{
Ok(result) => UseComputerResult::Success(result),
Err(error) => UseComputerResult::Error(error),
}
},
|res, _ctx| AIAgentActionResultType::UseComputer(res),
)
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for UseComputerExecutor {
type Event = ();
}
@@ -0,0 +1,99 @@
mod binary_detection {
use std::io::Write as _;
use async_io::block_on;
use tempfile::TempDir;
use super::super::{is_file_content_binary_async, should_read_as_binary};
fn write_file(dir: &TempDir, name: &str, contents: &[u8]) -> std::path::PathBuf {
let path = dir.path().join(name);
let mut file = std::fs::File::create(&path).expect("create temp file");
file.write_all(contents).expect("write temp file");
file.flush().expect("flush temp file");
path
}
#[test]
fn text_file_with_known_extension_is_not_binary() {
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "script.sh", b"#!/usr/bin/env bash\necho hi\n");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn binary_file_with_known_extension_is_binary() {
let dir = TempDir::new().expect("create tempdir");
// Known binary extension — should be classified as binary without
// needing content inspection.
let path = write_file(&dir, "image.png", b"not really a png but extension wins\n");
assert!(block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_shell_script_is_not_binary() {
// Regression test for QUALITY-507: an extensionless shell script (e.g.
// `script/linux/bundle`) was being classified as binary solely because
// its basename isn't in the known extensionless-text allow-list.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(
&dir,
"bundle",
b"#!/usr/bin/env bash\n#\n# Builds a Warp binary and bundles it up for distribution.\n\nset -e\n",
);
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_binary_content_is_binary() {
// An extensionless file whose contents are actually binary should fall
// through the content-based check and be classified as binary.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(
&dir,
"payload",
// NUL byte is a strong binary signal for content_inspector.
&[0u8, 1, 2, 3, b'A', 0, 0, 0, 0xFF, 0xFE, 0xFD],
);
assert!(block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_text_allowlisted_is_not_binary() {
// Files whose basenames are in the known text allow-list (e.g. README)
// should take the fast path and skip content inspection.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "README", b"Hello, world!\n");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn empty_extensionless_file_is_not_binary() {
// `content_inspector` treats an empty buffer as text, which is the
// desired behavior for `read_files`: an empty file should be
// surfaced to the agent as an empty string, not as zero binary bytes.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "empty", b"");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn missing_extensionless_file_is_classified_as_binary() {
// When an extensionless file cannot be opened during content
// inspection, `should_read_as_binary` must route to the binary path
// so the binary reader can produce a consistent `Missing` result.
let dir = TempDir::new().expect("create tempdir");
let missing = dir.path().join("does-not-exist");
assert!(block_on(should_read_as_binary(&missing)));
}
#[test]
fn missing_file_helper_is_classified_as_binary() {
// Direct coverage of the low-level helper: opening a non-existent
// path must return `true` so the caller doesn't accidentally try the
// text path on an unreadable file.
let dir = TempDir::new().expect("create tempdir");
let missing = dir.path().join("does-not-exist");
assert!(block_on(is_file_content_binary_async(&missing)));
}
}
@@ -0,0 +1,145 @@
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
use crate::ai::agent::{AIAgentAction, AIAgentActionId};
/// A unique ID for a batch of preprocessed actions.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct PreprocessId(String);
impl PreprocessId {
fn new() -> Self {
Self(Uuid::new_v4().to_string())
}
}
/// A list of pending preprocessed actions.
/// Each action goes through a preprocessing step where executors
/// can asynchronously do arbitrary work and store any state as needed.
/// Upon completing the preprocessing step for a batch of actions, consumers can
/// call `handle_process_actions_result` to get the actions that are ready to be queued.
#[derive(Default, Debug)]
pub(super) struct PendingPreprocessedActions(VecDeque<PreprocessActionBatch>);
impl PendingPreprocessedActions {
pub fn contains(&self, action_id: &AIAgentActionId) -> bool {
self.0.iter().any(|action| action.contains(action_id))
}
/// Returns the actions that are ready to be queued now that the group of actions identified by [`PreprocessId`] have completed.
/// NOTE this may return actions that have been completed earlier to maintain the invariant that actions are returned in the
/// order they are added.
pub fn handle_preprocess_actions_result(
&mut self,
preprocess_id: PreprocessId,
actions: Vec<AIAgentAction>,
) -> Vec<AIAgentAction> {
let mut actions_to_queue = Vec::with_capacity(actions.len());
// Find the index of the action with the given preprocess_id
let Some(current_index) = self.0.iter().position(|batch| batch.id == preprocess_id) else {
log::warn!("Action not found in list of preprocessed actions");
return vec![];
};
// Check if there are any pending actions before the current one
let has_pending_before = self
.0
.iter()
.take(current_index)
.any(|action| matches!(action.status, PreprocessActionStatus::Pending));
if has_pending_before {
// If there are pending actions before this one, just mark this one as done
// and don't return any actions yet.
self.0[current_index].status = PreprocessActionStatus::Done { actions };
vec![]
} else {
// All actions before this one are done: process them all.
// First, collect actions from all completed batches before this one
for action in self.0.drain(..current_index) {
match action.status {
PreprocessActionStatus::Pending => {
#[cfg(debug_assertions)]
panic!("Preprocess action batch should be completed but was pending")
}
PreprocessActionStatus::Done { actions } => {
actions_to_queue.extend(actions);
}
}
}
// Then add the current batch's actions.
actions_to_queue.extend(actions);
// Remove the current batch.
self.0.pop_front();
// Process any subsequent completed batches.
while let Some(action) = self.0.pop_front() {
match action.status {
PreprocessActionStatus::Pending => {
self.0.push_front(action);
break;
}
PreprocessActionStatus::Done { actions } => {
actions_to_queue.extend(actions);
}
}
}
actions_to_queue
}
}
/// Inserts a batch of actions that need to be preprocessed. Returns a [`PreprocessId`] that
/// uniquely identifies the batch.
pub fn insert_preprocess_action_batch(
&mut self,
action_ids: HashSet<AIAgentActionId>,
) -> PreprocessId {
let preprocess_id = PreprocessId::new();
self.0.push_back(PreprocessActionBatch::new(
preprocess_id.clone(),
action_ids,
));
preprocess_id
}
}
#[derive(Clone, Debug, PartialEq)]
enum PreprocessActionStatus {
Pending,
Done { actions: Vec<AIAgentAction> },
}
/// A batch of actions that need to be preprocessed.
#[derive(Clone, Debug, PartialEq)]
struct PreprocessActionBatch {
/// A unique identifier for this batch.
id: PreprocessId,
/// The current status of this batch.
status: PreprocessActionStatus,
/// Action IDs associated with this batch.
action_ids: HashSet<AIAgentActionId>,
}
impl PreprocessActionBatch {
fn contains(&self, action_id: &AIAgentActionId) -> bool {
self.action_ids.contains(action_id)
}
fn new(preprocess_id: PreprocessId, action_ids: HashSet<AIAgentActionId>) -> Self {
Self {
id: preprocess_id,
status: PreprocessActionStatus::Pending,
action_ids,
}
}
}
#[cfg(test)]
#[path = "preprocess_tests.rs"]
mod tests;
@@ -0,0 +1,99 @@
use super::*;
use crate::ai::agent::{task::TaskId, AIAgentAction, AIAgentActionId, AIAgentActionType};
use std::collections::HashSet;
fn create_test_action(id: AIAgentActionId) -> AIAgentAction {
AIAgentAction {
id,
task_id: TaskId::new("fake-task".to_owned()),
action: AIAgentActionType::RequestCommandOutput {
command: "test".to_string(),
is_read_only: None,
is_risky: None,
rationale: None,
uses_pager: None,
wait_until_completion: true,
citations: vec![],
},
requires_result: false,
}
}
fn generate_new_action_id() -> AIAgentActionId {
AIAgentActionId::from(uuid::Uuid::new_v4().to_string())
}
#[test]
fn test_single_batch_done() {
let mut actions = PendingPreprocessedActions::default();
let action_id = generate_new_action_id();
let mut action_ids = HashSet::new();
action_ids.insert(action_id.clone());
let preprocess_id = actions.insert_preprocess_action_batch(action_ids);
let test_action = create_test_action(action_id.clone());
let result_actions = vec![test_action.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id, result_actions);
// Verify the action is returned
assert_eq!(queued_actions.len(), 1);
assert_eq!(queued_actions[0].id, action_id);
// Verify the batch is removed
assert_eq!(actions.0.len(), 0);
}
#[test]
fn test_multiple_batches() {
let mut actions = PendingPreprocessedActions::default();
// Insert three batches.
let action_id1 = generate_new_action_id();
let mut action_ids1 = HashSet::new();
action_ids1.insert(action_id1.clone());
let preprocess_id1 = actions.insert_preprocess_action_batch(action_ids1);
let action_id2 = generate_new_action_id();
let mut action_ids2 = HashSet::new();
action_ids2.insert(action_id2.clone());
let preprocess_id2 = actions.insert_preprocess_action_batch(action_ids2);
let action_id3 = generate_new_action_id();
let mut action_ids3 = HashSet::new();
action_ids3.insert(action_id3.clone());
let preprocess_id3 = actions.insert_preprocess_action_batch(action_ids3);
// Process the last batch. Should return nothing since the batch is not done.
let test_action3 = create_test_action(action_id3.clone());
let result_actions3 = vec![test_action3.clone()];
let queued_actions3 = actions.handle_preprocess_actions_result(preprocess_id3, result_actions3);
// Nothing should be returned: the first two batches are not done.
assert_eq!(queued_actions3.len(), 0);
assert_eq!(actions.0.len(), 3);
// Process the second-to-last-batch.
let test_action2 = create_test_action(action_id2.clone());
let result_actions2 = vec![test_action2.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id2, result_actions2);
// Nothing should be returned: the first batch is not done.
assert_eq!(queued_actions.len(), 0);
assert_eq!(actions.0.len(), 3);
// Process the first batch.
let test_action1 = create_test_action(action_id1.clone());
let result_actions1 = vec![test_action1.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id1, result_actions1);
// All of the batches are done--they should all be returned and the internal queue should be empty.
assert_eq!(queued_actions.len(), 3);
assert_eq!(actions.0.len(), 0);
assert_eq!(queued_actions[0].id, action_id1);
assert_eq!(queued_actions[1].id, action_id2);
assert_eq!(queued_actions[2].id, action_id3);
}