From f0bf2e79d615082b424226cf1e25e58b9959b021 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 7 Aug 2025 16:25:54 +0200 Subject: [PATCH 01/22] Checkpoint --- Cargo.lock | 2 - Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 9 + crates/agent2/src/agent.rs | 9 +- crates/agent2/src/agent2.rs | 1 + crates/agent2/src/tests/test_tools.rs | 55 +++++- crates/agent2/src/thread.rs | 111 ++++++----- crates/agent2/src/tools.rs | 4 +- crates/agent2/src/tools/find_path_tool.rs | 231 ++++++++++++++++++++++ crates/agent2/src/tools/glob.rs | 80 -------- crates/agent_servers/src/acp/v0.rs | 1 + crates/agent_servers/src/claude/tools.rs | 1 + crates/agent_ui/src/acp/thread_view.rs | 1 + 13 files changed, 369 insertions(+), 138 deletions(-) create mode 100644 crates/agent2/src/tools/find_path_tool.rs delete mode 100644 crates/agent2/src/tools/glob.rs diff --git a/Cargo.lock b/Cargo.lock index e6a0b6c75f..234b107407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,8 +139,6 @@ dependencies = [ [[package]] name = "agent-client-protocol" version = "0.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ae3c22c23b64a5c3b7fc8a86fcc7c494e989bd2cd66fdce14a58cfc8078381" dependencies = [ "anyhow", "futures 0.3.31", diff --git a/Cargo.toml b/Cargo.toml index 6bff713aaa..e38c432ccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.21" +agent-client-protocol = { path = "../agent-client-protocol" } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 1671003023..71827d6948 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -167,6 +167,7 @@ pub struct ToolCall { pub status: ToolCallStatus, pub locations: Vec, pub raw_input: Option, + pub raw_output: Option, } impl ToolCall { @@ -195,6 +196,7 @@ impl ToolCall { locations: tool_call.locations, status, raw_input: tool_call.raw_input, + raw_output: tool_call.raw_output, } } @@ -211,6 +213,7 @@ impl ToolCall { content, locations, raw_input, + raw_output, } = fields; if let Some(kind) = kind { @@ -241,6 +244,10 @@ impl ToolCall { if let Some(raw_input) = raw_input { self.raw_input = Some(raw_input); } + + if let Some(raw_output) = raw_output { + self.raw_output = Some(raw_output); + } } pub fn diffs(&self) -> impl Iterator { @@ -1547,6 +1554,7 @@ mod tests { content: vec![], locations: vec![], raw_input: None, + raw_output: None, }), cx, ) @@ -1659,6 +1667,7 @@ mod tests { }], locations: vec![], raw_input: None, + raw_output: None, }), cx, ) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 5c0acb3fb1..8a670b2478 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,5 +1,5 @@ -use crate::ToolCallAuthorization; use crate::{templates::Templates, AgentResponseEvent, Thread}; +use crate::{FindPathTool, ToolCallAuthorization}; use acp_thread::ModelSelector; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -412,7 +412,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { anyhow!("No default model configured. Please configure a default model in settings.") })?; - let thread = cx.new(|_| Thread::new(project, agent.project_context.clone(), action_log, agent.templates.clone(), default_model)); + let thread = cx.new(|_| { + let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log, agent.templates.clone(), default_model); + thread.add_tool(FindPathTool::new(project.clone())); + thread + }); + Ok(thread) }, )??; diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index d759f63d89..db743c8429 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -10,3 +10,4 @@ mod tests; pub use agent::*; pub use native_agent_server::NativeAgentServer; pub use thread::*; +pub use tools::*; diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index a066bb982e..67e3de7c83 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -19,11 +19,20 @@ impl AgentTool for EchoTool { "echo".into() } + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { false } - fn run(self: Arc, input: Self::Input, _cx: &mut App) -> Task> { + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { Task::ready(Ok(input.text)) } } @@ -44,11 +53,20 @@ impl AgentTool for DelayTool { "delay".into() } + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { false } - fn run(self: Arc, input: Self::Input, cx: &mut App) -> Task> + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> where Self: Sized, { @@ -71,11 +89,20 @@ impl AgentTool for ToolRequiringPermission { "tool_requiring_permission".into() } + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { true } - fn run(self: Arc, _input: Self::Input, cx: &mut App) -> Task> + fn run( + self: Arc, + _input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> where Self: Sized, { @@ -96,11 +123,20 @@ impl AgentTool for InfiniteTool { "infinite".into() } + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { false } - fn run(self: Arc, _input: Self::Input, cx: &mut App) -> Task> { + fn run( + self: Arc, + _input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { cx.foreground_executor().spawn(async move { future::pending::<()>().await; unreachable!() @@ -137,11 +173,20 @@ impl AgentTool for WordListTool { "word_list".into() } + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other + } + fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { false } - fn run(self: Arc, _input: Self::Input, _cx: &mut App) -> Task> { + fn run( + self: Arc, + _input: Self::Input, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { Task::ready(Ok("ok".to_string())) } } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 9b17d7e37e..9d7a5688fe 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -8,9 +8,9 @@ use futures::{ channel::{mpsc, oneshot}, stream::FuturesUnordered, }; -use gpui::{App, Context, Entity, ImageFormat, SharedString, Task}; +use gpui::{App, Context, Entity, SharedString, Task}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason, @@ -276,7 +276,17 @@ impl Thread { while let Some(tool_result) = tool_uses.next().await { log::info!("Tool finished {:?}", tool_result); - event_stream.send_tool_call_result(&tool_result); + event_stream.send_tool_call_update( + &tool_result.tool_use_id, + acp::ToolCallUpdateFields { + status: Some(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }), + ..Default::default() + }, + ); thread .update(cx, |thread, _cx| { thread.pending_tool_uses.remove(&tool_result.tool_use_id); @@ -502,20 +512,20 @@ impl Thread { event_stream: AgentResponseEventStream, cx: &mut Context, ) -> Task> { + // TODO: should we push this down into the tool itself? let needs_authorization = tool.needs_authorization(tool_use.input.clone(), cx); cx.spawn(async move |_this, cx| { if needs_authorization? { event_stream.authorize_tool_call(&tool_use).await?; } - event_stream.send_tool_call_update( - &tool_use.id, - acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - ); - cx.update(|cx| tool.run(tool_use.input, cx))?.await + let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream); + tool_event_stream.send_update(acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }); + cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))? + .await }) } @@ -654,6 +664,7 @@ where type Input: for<'de> Deserialize<'de> + JsonSchema; fn name(&self) -> SharedString; + fn description(&self, _cx: &mut App) -> SharedString { let schema = schemars::schema_for!(Self::Input); SharedString::new( @@ -664,6 +675,8 @@ where ) } + fn kind(&self) -> acp::ToolKind; + /// Returns the JSON schema that describes the tool's input. fn input_schema(&self, _format: LanguageModelToolSchemaFormat) -> Schema { schemars::schema_for!(Self::Input) @@ -674,7 +687,12 @@ where fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool; /// Runs the tool with the provided input. - fn run(self: Arc, input: Self::Input, cx: &mut App) -> Task>; + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; fn erase(self) -> Arc { Arc::new(Erased(Arc::new(self))) @@ -688,7 +706,12 @@ pub trait AnyAgentTool { fn description(&self, cx: &mut App) -> SharedString; fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; fn needs_authorization(&self, input: serde_json::Value, cx: &mut App) -> Result; - fn run(self: Arc, input: serde_json::Value, cx: &mut App) -> Task>; + fn run( + self: Arc, + input: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; } impl AnyAgentTool for Erased> @@ -715,10 +738,15 @@ where } } - fn run(self: Arc, input: serde_json::Value, cx: &mut App) -> Task> { + fn run( + self: Arc, + input: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { let parsed_input: Result = serde_json::from_value(input).map_err(Into::into); match parsed_input { - Ok(input) => self.0.clone().run(input, cx), + Ok(input) => self.0.clone().run(input, event_stream, cx), Err(error) => Task::ready(Err(anyhow!(error))), } } @@ -758,6 +786,7 @@ impl AgentResponseEventStream { content: vec![], locations: vec![], raw_input: Some(tool_use.input.clone()), + raw_output: None, }, options: vec![ acp::PermissionOption { @@ -798,6 +827,7 @@ impl AgentResponseEventStream { content: vec![], locations: vec![], raw_input: Some(tool_use.input.clone()), + raw_output: None, }))) .ok(); } @@ -817,38 +847,6 @@ impl AgentResponseEventStream { .ok(); } - fn send_tool_call_result(&self, tool_result: &LanguageModelToolResult) { - let status = if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }; - let content = match &tool_result.content { - LanguageModelToolResultContent::Text(text) => text.to_string().into(), - LanguageModelToolResultContent::Image(LanguageModelImage { source, .. }) => { - acp::ToolCallContent::Content { - content: acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: source.to_string(), - mime_type: ImageFormat::Png.mime_type().to_string(), - }), - } - } - }; - self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( - acp::ToolCallUpdate { - id: acp::ToolCallId(tool_result.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - status: Some(status), - content: Some(vec![content]), - ..Default::default() - }, - }, - ))) - .ok(); - } - fn send_stop(&self, reason: StopReason) { match reason { StopReason::EndTurn => { @@ -874,3 +872,22 @@ impl AgentResponseEventStream { self.0.unbounded_send(Err(error)).ok(); } } + +#[derive(Clone)] +pub struct ToolCallEventStream { + tool_use_id: LanguageModelToolUseId, + stream: AgentResponseEventStream, +} + +impl ToolCallEventStream { + fn new(tool_use_id: LanguageModelToolUseId, stream: AgentResponseEventStream) -> Self { + Self { + tool_use_id, + stream, + } + } + + pub fn send_update(&self, fields: acp::ToolCallUpdateFields) { + self.stream.send_tool_call_update(&self.tool_use_id, fields); + } +} diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index cf3162abfa..906992fc52 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1 +1,3 @@ -mod glob; +mod find_path_tool; + +pub use find_path_tool::*; diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs new file mode 100644 index 0000000000..1c0b8d6590 --- /dev/null +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -0,0 +1,231 @@ +use agent_client_protocol as acp; +use anyhow::{anyhow, Result}; +use gpui::{App, AppContext, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::{cmp, path::PathBuf, sync::Arc}; +use util::paths::PathMatcher; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Fast file path pattern matching tool that works with any codebase size +/// +/// - Supports glob patterns like "**/*.js" or "src/**/*.ts" +/// - Returns matching file paths sorted alphabetically +/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths. +/// - Use this tool when you need to find files by name patterns +/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FindPathToolInput { + /// The glob to match against every path in the project. + /// + /// + /// If the project has the following root directories: + /// + /// - directory1/a/something.txt + /// - directory2/a/things.txt + /// - directory3/a/other.txt + /// + /// You can get back the first two paths by providing a glob of "*thing*.txt" + /// + pub glob: String, + + /// Optional starting position for paginated results (0-based). + /// When not provided, starts from the beginning. + #[serde(default)] + pub offset: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FindPathToolOutput { + paths: Vec, +} + +const RESULTS_PER_PAGE: usize = 50; + +pub struct FindPathTool { + project: Entity, +} + +impl FindPathTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for FindPathTool { + type Input = FindPathToolInput; + + fn name(&self) -> SharedString { + "find_path".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Search + } + + fn needs_authorization(&self, _: Self::Input, _: &App) -> bool { + false + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let search_paths_task = search_paths(&input.glob, self.project.clone(), cx); + + cx.background_spawn(async move { + let matches = search_paths_task.await?; + let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) + ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; + + event_stream.send_update(acp::ToolCallUpdateFields { + title: Some(if paginated_matches.len() == 0 { + "No matches".into() + } else if paginated_matches.len() == 1 { + "1 match".into() + } else { + format!("{} matches", paginated_matches.len()).into() + }), + content: Some( + paginated_matches + .iter() + .map(|path| acp::ToolCallContent::Content { + content: acp::ContentBlock::ResourceLink(acp::ResourceLink { + uri: format!("file://{}", path.display()), + name: path.to_string_lossy().into(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + }), + }) + .collect(), + ), + raw_output: Some(serde_json::json!({ + "paths": &matches, + })), + ..Default::default() + }); + + if matches.is_empty() { + Ok("No matches found".into()) + } else { + let mut message = format!("Found {} total matches.", matches.len()); + if matches.len() > RESULTS_PER_PAGE { + write!( + &mut message, + "\nShowing results {}-{} (provide 'offset' parameter for more results):", + input.offset + 1, + input.offset + paginated_matches.len() + ) + .unwrap(); + } + + for mat in matches.iter().skip(input.offset).take(RESULTS_PER_PAGE) { + write!(&mut message, "\n{}", mat.display()).unwrap(); + } + + Ok(message) + } + }) + } +} + +fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task>> { + let path_matcher = match PathMatcher::new([ + // Sometimes models try to search for "". In this case, return all paths in the project. + if glob.is_empty() { "*" } else { glob }, + ]) { + Ok(matcher) => matcher, + Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), + }; + let snapshots: Vec<_> = project + .read(cx) + .worktrees(cx) + .map(|worktree| worktree.read(cx).snapshot()) + .collect(); + + cx.background_spawn(async move { + Ok(snapshots + .iter() + .flat_map(|snapshot| { + let root_name = PathBuf::from(snapshot.root_name()); + snapshot + .entries(false, 0) + .map(move |entry| root_name.join(&entry.path)) + .filter(|path| path_matcher.is_match(&path)) + }) + .collect()) + }) +} + +#[cfg(test)] +mod test { + use super::*; + use gpui::TestAppContext; + use project::{FakeFs, Project}; + use settings::SettingsStore; + use util::path; + + #[gpui::test] + async fn test_find_path_tool(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + serde_json::json!({ + "apple": { + "banana": { + "carrot": "1", + }, + "bandana": { + "carbonara": "2", + }, + "endive": "3" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let matches = cx + .update(|cx| search_paths("root/**/car*", project.clone(), cx)) + .await + .unwrap(); + assert_eq!( + matches, + &[ + PathBuf::from("root/apple/banana/carrot"), + PathBuf::from("root/apple/bandana/carbonara") + ] + ); + + let matches = cx + .update(|cx| search_paths("**/car*", project.clone(), cx)) + .await + .unwrap(); + assert_eq!( + matches, + &[ + PathBuf::from("root/apple/banana/carrot"), + PathBuf::from("root/apple/bandana/carbonara") + ] + ); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } +} diff --git a/crates/agent2/src/tools/glob.rs b/crates/agent2/src/tools/glob.rs deleted file mode 100644 index f44ce9f359..0000000000 --- a/crates/agent2/src/tools/glob.rs +++ /dev/null @@ -1,80 +0,0 @@ -use anyhow::{anyhow, Result}; -use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::Project; -use schemars::JsonSchema; -use serde::Deserialize; -use std::{path::PathBuf, sync::Arc}; -use util::paths::PathMatcher; -use worktree::Snapshot as WorktreeSnapshot; - -use crate::{ - templates::{GlobTemplate, Template, Templates}, - thread::AgentTool, -}; - -// Description is dynamic, see `fn description` below -#[derive(Deserialize, JsonSchema)] -struct GlobInput { - /// A POSIX glob pattern - glob: SharedString, -} - -struct GlobTool { - project: Entity, - templates: Arc, -} - -impl AgentTool for GlobTool { - type Input = GlobInput; - - fn name(&self) -> SharedString { - "glob".into() - } - - fn description(&self, cx: &mut App) -> SharedString { - let project_roots = self - .project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).root_name().into()) - .collect::>() - .join("\n"); - - GlobTemplate { project_roots } - .render(&self.templates) - .expect("template failed to render") - .into() - } - - fn needs_authorization(&self, _input: Self::Input, _cx: &App) -> bool { - false - } - - fn run(self: Arc, input: Self::Input, cx: &mut App) -> Task> { - let path_matcher = match PathMatcher::new([&input.glob]) { - Ok(matcher) => matcher, - Err(error) => return Task::ready(Err(anyhow!(error))), - }; - - let snapshots: Vec = self - .project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect(); - - cx.background_spawn(async move { - let paths = snapshots.iter().flat_map(|snapshot| { - let root_name = PathBuf::from(snapshot.root_name()); - snapshot - .entries(false, 0) - .map(move |entry| root_name.join(&entry.path)) - .filter(|path| path_matcher.is_match(&path)) - }); - let output = paths - .map(|path| format!("{}\n", path.display())) - .collect::(); - Ok(output) - }) - } -} diff --git a/crates/agent_servers/src/acp/v0.rs b/crates/agent_servers/src/acp/v0.rs index e676b7ee46..8d85435f92 100644 --- a/crates/agent_servers/src/acp/v0.rs +++ b/crates/agent_servers/src/acp/v0.rs @@ -280,6 +280,7 @@ fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) .map(into_new_tool_call_location) .collect(), raw_input: None, + raw_output: None, } } diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs index 85b9a13642..7ca150c0bd 100644 --- a/crates/agent_servers/src/claude/tools.rs +++ b/crates/agent_servers/src/claude/tools.rs @@ -297,6 +297,7 @@ impl ClaudeTool { content: self.content(), locations: self.locations(), raw_input: None, + raw_output: None, } } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 06e47a11dc..1bd495d1aa 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2962,6 +2962,7 @@ mod tests { content: vec!["hi".into()], locations: vec![], raw_input: None, + raw_output: None, }; let connection = StubAgentConnection::new(vec![acp::SessionUpdate::ToolCall(tool_call)]) .with_permission_requests(HashMap::from_iter([( From 6dda5b9d86a02c15791cbfe6995180a8f6c458ce Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 7 Aug 2025 16:33:42 +0200 Subject: [PATCH 02/22] Checkpoint --- crates/agent2/src/thread.rs | 76 ++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 9d7a5688fe..efc58d12b1 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -434,6 +434,21 @@ impl Thread { event_stream: &AgentResponseEventStream, cx: &mut Context, ) -> Option> { + let Some(tool) = self.tools.get(tool_use.name.as_ref()).cloned() else { + if tool_use.is_input_complete { + let content = format!("No tool named {} exists", tool_use.name); + return Some(Task::ready(LanguageModelToolResult { + content: LanguageModelToolResultContent::Text(Arc::from(content)), + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + } else { + return None; + } + }; + cx.notify(); self.pending_tool_uses @@ -454,7 +469,7 @@ impl Thread { } }); if push_new_tool_use { - event_stream.send_tool_call(&tool_use); + event_stream.send_tool_call(&tool_use, tool.kind()); last_message .content .push(MessageContent::ToolUse(tool_use.clone())); @@ -472,37 +487,25 @@ impl Thread { return None; } - if let Some(tool) = self.tools.get(tool_use.name.as_ref()) { - let tool_result = - self.run_tool(tool.clone(), tool_use.clone(), event_stream.clone(), cx); - Some(cx.foreground_executor().spawn(async move { - match tool_result.await { - Ok(tool_output) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: false, - content: LanguageModelToolResultContent::Text(Arc::from(tool_output)), - output: None, - }, - Err(error) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: None, - }, - } - })) - } else { - let content = format!("No tool named {} exists", tool_use.name); - Some(Task::ready(LanguageModelToolResult { - content: LanguageModelToolResultContent::Text(Arc::from(content)), - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })) - } + let tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx); + Some(cx.foreground_executor().spawn(async move { + match tool_result.await { + Ok(tool_output) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: false, + content: LanguageModelToolResultContent::Text(Arc::from(tool_output)), + output: None, + }, + Err(error) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), + output: None, + }, + } + })) } fn run_tool( @@ -704,6 +707,7 @@ pub struct Erased(T); pub trait AnyAgentTool { fn name(&self) -> SharedString; fn description(&self, cx: &mut App) -> SharedString; + fn kind(&self) -> acp::ToolKind; fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; fn needs_authorization(&self, input: serde_json::Value, cx: &mut App) -> Result; fn run( @@ -726,6 +730,10 @@ where self.0.description(cx) } + fn kind(&self) -> agent_client_protocol::ToolKind { + self.0.kind() + } + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { Ok(serde_json::to_value(self.0.input_schema(format))?) } @@ -817,12 +825,12 @@ impl AgentResponseEventStream { } } - fn send_tool_call(&self, tool_use: &LanguageModelToolUse) { + fn send_tool_call(&self, tool_use: &LanguageModelToolUse, kind: acp::ToolKind) { self.0 .unbounded_send(Ok(AgentResponseEvent::ToolCall(acp::ToolCall { id: acp::ToolCallId(tool_use.id.to_string().into()), title: tool_use.name.to_string(), - kind: acp::ToolKind::Other, + kind, status: acp::ToolCallStatus::Pending, content: vec![], locations: vec![], From bf5c097732dde6a557b82464ad6ec709faa4f516 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 16:44:56 +0200 Subject: [PATCH 03/22] Switch back to upstream acp crate --- Cargo.lock | 4 +++- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 234b107407..9204f18558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.0.21" +version = "0.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ea41139e9680f53bbfd0d3a60d92f2832e00645f2ffb1365f76992ff2f6a79" dependencies = [ "anyhow", "futures 0.3.31", diff --git a/Cargo.toml b/Cargo.toml index e38c432ccf..e3df6925ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" } # agentic-coding-protocol = "0.0.10" -agent-client-protocol = { path = "../agent-client-protocol" } +agent-client-protocol = "0.0.22" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" From 342247f60f03f6caa12c01077b2f19f1c0a431b8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 16:59:54 +0200 Subject: [PATCH 04/22] clippy fix --- crates/agent2/src/tools/find_path_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 1c0b8d6590..d51294c5a2 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -89,7 +89,7 @@ impl AgentTool for FindPathTool { } else if paginated_matches.len() == 1 { "1 match".into() } else { - format!("{} matches", paginated_matches.len()).into() + format!("{} matches", paginated_matches.len()) }), content: Some( paginated_matches From 0d24686a9ca18c752ab5f90f50b9a725d73d3c44 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 17:29:45 +0200 Subject: [PATCH 05/22] Fix tool hallucination event order --- crates/agent2/src/tests/mod.rs | 57 ++++++++++++++++++++++++++++++++++ crates/agent2/src/thread.rs | 37 ++++++++++++---------- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index b13b1cbe1a..297453f1e6 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -286,6 +286,63 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_tool_hallucination(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "abc", cx)); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_1".into(), + name: "nonexistent_tool".into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!(tool_call.title, "nonexistent_tool"); + assert_eq!(tool_call.status, acp::ToolCallStatus::Pending); + let update = expect_tool_call_update(&mut events).await; + assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); +} + +async fn expect_tool_call( + events: &mut UnboundedReceiver>, +) -> acp::ToolCall { + let event = events + .next() + .await + .expect("no tool call authorization event received") + .unwrap(); + match event { + AgentResponseEvent::ToolCall(tool_call) => return tool_call, + event => { + panic!("Unexpected event {event:?}"); + } + } +} + +async fn expect_tool_call_update( + events: &mut UnboundedReceiver>, +) -> acp::ToolCallUpdate { + let event = events + .next() + .await + .expect("no tool call authorization event received") + .unwrap(); + match event { + AgentResponseEvent::ToolCallUpdate(tool_call_update) => return tool_call_update, + event => { + panic!("Unexpected event {event:?}"); + } + } +} + async fn next_tool_call_authorization( events: &mut UnboundedReceiver>, ) -> ToolCallAuthorization { diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index efc58d12b1..8f8fae5c67 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -434,23 +434,10 @@ impl Thread { event_stream: &AgentResponseEventStream, cx: &mut Context, ) -> Option> { - let Some(tool) = self.tools.get(tool_use.name.as_ref()).cloned() else { - if tool_use.is_input_complete { - let content = format!("No tool named {} exists", tool_use.name); - return Some(Task::ready(LanguageModelToolResult { - content: LanguageModelToolResultContent::Text(Arc::from(content)), - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })); - } else { - return None; - } - }; - cx.notify(); + let tool = self.tools.get(tool_use.name.as_ref()).cloned(); + self.pending_tool_uses .insert(tool_use.id.clone(), tool_use.clone()); let last_message = self.last_assistant_message(); @@ -468,8 +455,15 @@ impl Thread { true } }); + if push_new_tool_use { - event_stream.send_tool_call(&tool_use, tool.kind()); + event_stream.send_tool_call( + &tool_use, + // todo! add default + tool.as_ref() + .map(|t| t.kind()) + .unwrap_or(acp::ToolKind::Other), + ); last_message .content .push(MessageContent::ToolUse(tool_use.clone())); @@ -487,6 +481,17 @@ impl Thread { return None; } + let Some(tool) = tool else { + let content = format!("No tool named {} exists", tool_use.name); + return Some(Task::ready(LanguageModelToolResult { + content: LanguageModelToolResultContent::Text(Arc::from(content)), + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + }; + let tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx); Some(cx.foreground_executor().spawn(async move { match tool_result.await { From c050527d9096da61f71d7e748616d5363a2ce832 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 17:30:37 +0200 Subject: [PATCH 06/22] remove todo --- crates/agent2/src/thread.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 8f8fae5c67..70cbde1449 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -459,7 +459,6 @@ impl Thread { if push_new_tool_use { event_stream.send_tool_call( &tool_use, - // todo! add default tool.as_ref() .map(|t| t.kind()) .unwrap_or(acp::ToolKind::Other), From c943f5a847c94d3c0381920f5e07583a4deed74b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 17:59:55 +0200 Subject: [PATCH 07/22] remove glob template --- crates/agent2/src/templates.rs | 13 ------------- crates/agent2/src/templates/glob.hbs | 8 -------- 2 files changed, 21 deletions(-) delete mode 100644 crates/agent2/src/templates/glob.hbs diff --git a/crates/agent2/src/templates.rs b/crates/agent2/src/templates.rs index e634d414d6..a63f0ad206 100644 --- a/crates/agent2/src/templates.rs +++ b/crates/agent2/src/templates.rs @@ -33,19 +33,6 @@ pub trait Template: Sized { } } -#[expect( - dead_code, - reason = "Marked as unused by Rust 1.89 and left as is as of 07 Aug 2025 to let AI team address it." -)] -#[derive(Serialize)] -pub struct GlobTemplate { - pub project_roots: String, -} - -impl Template for GlobTemplate { - const TEMPLATE_NAME: &'static str = "glob.hbs"; -} - #[derive(Serialize)] pub struct SystemPromptTemplate<'a> { #[serde(flatten)] diff --git a/crates/agent2/src/templates/glob.hbs b/crates/agent2/src/templates/glob.hbs deleted file mode 100644 index 3bf992b093..0000000000 --- a/crates/agent2/src/templates/glob.hbs +++ /dev/null @@ -1,8 +0,0 @@ -Find paths on disk with glob patterns. - -Assume that all glob patterns are matched in a project directory with the following entries. - -{{project_roots}} - -When searching with patterns that begin with literal path components, e.g. `foo/bar/**/*.rs`, be -sure to anchor them with one of the directories listed above. From 6769b650d0ebd5889dc2a1696d4b6bc27073f84a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 Aug 2025 18:35:41 +0200 Subject: [PATCH 08/22] Remove unneeded worktree dep --- crates/agent2/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 21a043fd98..884378fbcc 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -39,7 +39,6 @@ ui.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true -worktree.workspace = true workspace-hack.workspace = true [dev-dependencies] From 160e6d574770c0654e54964b6fdb71db4f47930f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 7 Aug 2025 19:00:08 +0200 Subject: [PATCH 09/22] WIP --- crates/acp_thread/src/acp_thread.rs | 133 ++++---------------- crates/acp_thread/src/diff.rs | 161 +++++++++++++++++++++++++ crates/agent2/src/agent.rs | 9 ++ crates/agent2/src/thread.rs | 7 ++ crates/agent_ui/src/acp/thread_view.rs | 15 ++- 5 files changed, 211 insertions(+), 114 deletions(-) create mode 100644 crates/acp_thread/src/diff.rs diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 71827d6948..7a00bd2320 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1,19 +1,18 @@ mod connection; +mod diff; + pub use connection::*; +pub use diff::*; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; use assistant_tool::ActionLog; -use buffer_diff::BufferDiff; -use editor::{Bias, MultiBuffer, PathKey}; +use editor::Bias; use futures::future::{Fuse, FusedFuture}; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; use itertools::Itertools; -use language::{ - Anchor, Buffer, BufferSnapshot, Capability, LanguageRegistry, OffsetRangeExt as _, Point, - text_diff, -}; +use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, text_diff}; use markdown::Markdown; use project::{AgentLocation, Project}; use std::collections::HashMap; @@ -141,7 +140,7 @@ impl AgentThreadEntry { } } - pub fn diffs(&self) -> impl Iterator { + pub fn diffs(&self) -> impl Iterator> { if let AgentThreadEntry::ToolCall(call) = self { itertools::Either::Left(call.diffs()) } else { @@ -250,7 +249,7 @@ impl ToolCall { } } - pub fn diffs(&self) -> impl Iterator { + pub fn diffs(&self) -> impl Iterator> { self.content.iter().filter_map(|content| match content { ToolCallContent::ContentBlock { .. } => None, ToolCallContent::Diff { diff } => Some(diff), @@ -390,7 +389,7 @@ impl ContentBlock { #[derive(Debug)] pub enum ToolCallContent { ContentBlock { content: ContentBlock }, - Diff { diff: Diff }, + Diff { diff: Entity }, } impl ToolCallContent { @@ -404,7 +403,7 @@ impl ToolCallContent { content: ContentBlock::new(content, &language_registry, cx), }, acp::ToolCallContent::Diff { diff } => Self::Diff { - diff: Diff::from_acp(diff, language_registry, cx), + diff: cx.new(|cx| Diff::from_acp(diff, language_registry, cx)), }, } } @@ -412,108 +411,11 @@ impl ToolCallContent { pub fn to_markdown(&self, cx: &App) -> String { match self { Self::ContentBlock { content } => content.to_markdown(cx).to_string(), - Self::Diff { diff } => diff.to_markdown(cx), + Self::Diff { diff } => diff.read(cx).to_markdown(cx), } } } -#[derive(Debug)] -pub struct Diff { - pub multibuffer: Entity, - pub path: PathBuf, - _task: Task>, -} - -impl Diff { - pub fn from_acp( - diff: acp::Diff, - language_registry: Arc, - cx: &mut App, - ) -> Self { - let acp::Diff { - path, - old_text, - new_text, - } = diff; - - let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); - - let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); - let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); - let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); - let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); - - let task = cx.spawn({ - let multibuffer = multibuffer.clone(); - let path = path.clone(); - async move |cx| { - let language = language_registry - .language_for_file_path(&path) - .await - .log_err(); - - new_buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; - - let old_buffer_snapshot = old_buffer.update(cx, |buffer, cx| { - buffer.set_language(language, cx); - buffer.snapshot() - })?; - - buffer_diff - .update(cx, |diff, cx| { - diff.set_base_text( - old_buffer_snapshot, - Some(language_registry), - new_buffer_snapshot, - cx, - ) - })? - .await?; - - multibuffer - .update(cx, |multibuffer, cx| { - let hunk_ranges = { - let buffer = new_buffer.read(cx); - let diff = buffer_diff.read(cx); - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) - .collect::>() - }; - - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&new_buffer, cx), - new_buffer.clone(), - hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - multibuffer.add_diff(buffer_diff, cx); - }) - .log_err(); - - anyhow::Ok(()) - } - }); - - Self { - multibuffer, - path, - _task: task, - } - } - - fn to_markdown(&self, cx: &App) -> String { - let buffer_text = self - .multibuffer - .read(cx) - .all_buffers() - .iter() - .map(|buffer| buffer.read(cx).text()) - .join("\n"); - format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text) - } -} - #[derive(Debug, Default)] pub struct Plan { pub entries: Vec, @@ -828,6 +730,21 @@ impl AcpThread { Ok(()) } + pub fn set_tool_call_diff( + &mut self, + tool_call_id: &acp::ToolCallId, + diff: Entity, + cx: &mut Context, + ) -> Result<()> { + let (ix, current_call) = self + .tool_call_mut(tool_call_id) + .context("Tool call not found")?; + current_call.content.clear(); + current_call.content.push(ToolCallContent::Diff { diff }); + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + Ok(()) + } + /// Updates a tool call if id matches an existing entry, otherwise inserts a new one. pub fn upsert_tool_call(&mut self, tool_call: acp::ToolCall, cx: &mut Context) { let status = ToolCallStatus::Allowed { diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs new file mode 100644 index 0000000000..01649eaebb --- /dev/null +++ b/crates/acp_thread/src/diff.rs @@ -0,0 +1,161 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use buffer_diff::BufferDiff; +use editor::{MultiBuffer, PathKey}; +use gpui::{App, AppContext, Context, Entity, Task}; +use itertools::Itertools; +use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use util::ResultExt; + +pub enum Diff { + Pending { + multibuffer: Entity, + base_text: Arc, + buffer: Entity, + buffer_diff: Entity, + }, + Ready { + path: PathBuf, + multibuffer: Entity, + _task: Task>, + }, +} + +impl Diff { + pub fn from_acp( + diff: acp::Diff, + language_registry: Arc, + cx: &mut Context, + ) -> Self { + let acp::Diff { + path, + old_text, + new_text, + } = diff; + + let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); + + let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); + let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); + let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); + + let task = cx.spawn({ + let multibuffer = multibuffer.clone(); + let path = path.clone(); + async move |_, cx| { + let language = language_registry + .language_for_file_path(&path) + .await + .log_err(); + + new_buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; + + let old_buffer_snapshot = old_buffer.update(cx, |buffer, cx| { + buffer.set_language(language, cx); + buffer.snapshot() + })?; + + buffer_diff + .update(cx, |diff, cx| { + diff.set_base_text( + old_buffer_snapshot, + Some(language_registry), + new_buffer_snapshot, + cx, + ) + })? + .await?; + + multibuffer + .update(cx, |multibuffer, cx| { + let hunk_ranges = { + let buffer = new_buffer.read(cx); + let diff = buffer_diff.read(cx); + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .collect::>() + }; + + multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&new_buffer, cx), + new_buffer.clone(), + hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff, cx); + }) + .log_err(); + + anyhow::Ok(()) + } + }); + + Self::Ready { + multibuffer, + path, + _task: task, + } + } + + pub fn new(buffer: Entity, cx: &mut Context) -> Self { + let buffer_snapshot = buffer.read(cx).snapshot(); + let base_text = buffer_snapshot.text(); + let language_registry = buffer.read(cx).language_registry(); + let text_snapshot = buffer.read(cx).text_snapshot(); + let buffer_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&text_snapshot, cx); + let _ = diff.set_base_text( + buffer_snapshot.clone(), + language_registry, + text_snapshot, + cx, + ); + diff + }); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::without_headers(Capability::ReadOnly); + multibuffer.add_diff(buffer_diff.clone(), cx); + multibuffer + }); + + Self::Pending { + multibuffer, + base_text: Arc::new(base_text), + buffer, + buffer_diff, + } + } + + pub fn multibuffer(&self) -> &Entity { + match self { + Self::Pending { multibuffer, .. } => multibuffer, + Self::Ready { multibuffer, .. } => multibuffer, + } + } + + pub fn to_markdown(&self, cx: &App) -> String { + let buffer_text = self + .multibuffer() + .read(cx) + .all_buffers() + .iter() + .map(|buffer| buffer.read(cx).text()) + .join("\n"); + let path = match self { + Diff::Pending { buffer, .. } => buffer.read(cx).file().map(|file| file.path().as_ref()), + Diff::Ready { path, .. } => Some(path.as_path()), + }; + format!( + "Diff: {}\n```\n{}\n```\n", + path.unwrap_or(Path::new("untitled")).display(), + buffer_text + ) + } +} diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 8a670b2478..4583688ad3 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -562,6 +562,15 @@ impl acp_thread::AgentConnection for NativeAgentConnection { ) })??; } + AgentResponseEvent::ToolCallDiff(tool_call_diff) => { + acp_thread.update(cx, |thread, cx| { + thread.set_tool_call_diff( + &tool_call_diff.tool_call_id, + tool_call_diff.diff, + cx, + ) + })??; + } AgentResponseEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); return Ok(acp::PromptResponse { stop_reason }); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 70cbde1449..9be2860eec 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -103,6 +103,7 @@ pub enum AgentResponseEvent { ToolCall(acp::ToolCall), ToolCallUpdate(acp::ToolCallUpdate), ToolCallAuthorization(ToolCallAuthorization), + ToolCallDiff(ToolCallDiff), Stop(acp::StopReason), } @@ -113,6 +114,12 @@ pub struct ToolCallAuthorization { pub response: oneshot::Sender, } +#[derive(Debug)] +pub struct ToolCallDiff { + pub tool_call_id: acp::ToolCallId, + pub diff: Entity, +} + pub struct Thread { messages: Vec, completion_mode: CompletionMode, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 1bd495d1aa..cf0cf43e33 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -42,7 +42,7 @@ use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; use ::acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff, + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, }; @@ -731,7 +731,11 @@ impl AcpThreadView { cx: &App, ) -> Option>> { let entry = self.thread()?.read(cx).entries().get(entry_ix)?; - Some(entry.diffs().map(|diff| diff.multibuffer.clone())) + Some( + entry + .diffs() + .map(|diff| diff.read(cx).multibuffer().clone()), + ) } fn authenticate( @@ -1313,10 +1317,9 @@ impl AcpThreadView { Empty.into_any_element() } } - ToolCallContent::Diff { - diff: Diff { multibuffer, .. }, - .. - } => self.render_diff_editor(multibuffer), + ToolCallContent::Diff { diff, .. } => { + self.render_diff_editor(&diff.read(cx).multibuffer()) + } } } From 168e55db5316029f13576c7d99c62acf8d66e84c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 7 Aug 2025 19:19:46 +0200 Subject: [PATCH 10/22] Checkpoint --- crates/acp_thread/src/diff.rs | 273 +++++++++++++++++++++++++++++++--- 1 file changed, 250 insertions(+), 23 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 01649eaebb..9cc6271360 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -1,28 +1,23 @@ use agent_client_protocol as acp; use anyhow::Result; -use buffer_diff::BufferDiff; +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::{MultiBuffer, PathKey}; -use gpui::{App, AppContext, Context, Entity, Task}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task}; use itertools::Itertools; -use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _}; +use language::{ + Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, Rope, TextBuffer, +}; use std::{ + cmp::Reverse, + ops::Range, path::{Path, PathBuf}, sync::Arc, }; use util::ResultExt; pub enum Diff { - Pending { - multibuffer: Entity, - base_text: Arc, - buffer: Entity, - buffer_diff: Entity, - }, - Ready { - path: PathBuf, - multibuffer: Entity, - _task: Task>, - }, + Pending(PendingDiff), + Finalized(FinalizedDiff), } impl Diff { @@ -96,11 +91,11 @@ impl Diff { } }); - Self::Ready { + Self::Finalized(FinalizedDiff { multibuffer, path, - _task: task, - } + _update_diff: task, + }) } pub fn new(buffer: Entity, cx: &mut Context) -> Self { @@ -125,18 +120,37 @@ impl Diff { multibuffer }); - Self::Pending { + Self::Pending(PendingDiff { multibuffer, base_text: Arc::new(base_text), + _subscription: cx.observe(&buffer, |this, _, cx| { + if let Diff::Pending(diff) = this { + diff.update(cx); + } + }), buffer, - buffer_diff, + diff: buffer_diff, + revealed_ranges: Vec::new(), + update_diff: Task::ready(Ok(())), + }) + } + + pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { + if let Self::Pending(diff) = self { + diff.reveal_range(range, cx); + } + } + + pub fn finalize(&mut self, cx: &mut Context) { + if let Self::Pending(diff) = self { + *self = Self::Finalized(diff.finalize(cx)); } } pub fn multibuffer(&self) -> &Entity { match self { - Self::Pending { multibuffer, .. } => multibuffer, - Self::Ready { multibuffer, .. } => multibuffer, + Self::Pending(PendingDiff { multibuffer, .. }) => multibuffer, + Self::Finalized(FinalizedDiff { multibuffer, .. }) => multibuffer, } } @@ -149,8 +163,10 @@ impl Diff { .map(|buffer| buffer.read(cx).text()) .join("\n"); let path = match self { - Diff::Pending { buffer, .. } => buffer.read(cx).file().map(|file| file.path().as_ref()), - Diff::Ready { path, .. } => Some(path.as_path()), + Diff::Pending(PendingDiff { buffer, .. }) => { + buffer.read(cx).file().map(|file| file.path().as_ref()) + } + Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_path()), }; format!( "Diff: {}\n```\n{}\n```\n", @@ -159,3 +175,214 @@ impl Diff { ) } } + +pub struct PendingDiff { + multibuffer: Entity, + base_text: Arc, + buffer: Entity, + diff: Entity, + revealed_ranges: Vec>, + _subscription: Subscription, + update_diff: Task>, +} + +impl PendingDiff { + pub fn update(&mut self, cx: &mut Context) { + let buffer = self.buffer.clone(); + let buffer_diff = self.diff.clone(); + let base_text = self.base_text.clone(); + self.update_diff = cx.spawn(async move |diff, cx| { + let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?; + let diff_snapshot = BufferDiff::update_diff( + buffer_diff.clone(), + text_snapshot.clone(), + Some(base_text), + false, + false, + None, + None, + cx, + ) + .await?; + buffer_diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &text_snapshot, cx) + })?; + diff.update(cx, |diff, cx| { + if let Diff::Pending(diff) = diff { + diff.update_visible_ranges(cx); + } + }) + }); + } + + pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { + self.revealed_ranges.push(range); + self.update_visible_ranges(cx); + } + + fn finalize(&self, cx: &mut Context) -> FinalizedDiff { + let ranges = self.excerpt_ranges(cx); + let base_text = self.base_text.clone(); + let language_registry = self.buffer.read(cx).language_registry().clone(); + + let path = self + .buffer + .read(cx) + .file() + .map(|file| file.path().as_ref()) + .unwrap_or(Path::new("untitled")) + .into(); + + // Replace the buffer in the multibuffer with the snapshot + let buffer = cx.new(|cx| { + let language = self.buffer.read(cx).language().cloned(); + let buffer = TextBuffer::new_normalized( + 0, + cx.entity_id().as_non_zero_u64().into(), + self.buffer.read(cx).line_ending(), + self.buffer.read(cx).as_rope().clone(), + ); + let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); + buffer.set_language(language, cx); + buffer + }); + + let buffer_diff = cx.spawn({ + let buffer = buffer.clone(); + let language_registry = language_registry.clone(); + async move |_this, cx| { + build_buffer_diff(base_text, &buffer, language_registry, cx).await + } + }); + + let update_diff = cx.spawn(async move |this, cx| { + let buffer_diff = buffer_diff.await?; + this.update(cx, |this, cx| { + this.multibuffer().update(cx, |multibuffer, cx| { + let path_key = PathKey::for_buffer(&buffer, cx); + multibuffer.clear(cx); + multibuffer.set_excerpts_for_path( + path_key, + buffer, + ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + }); + + cx.notify(); + }) + }); + + FinalizedDiff { + path, + multibuffer: self.multibuffer.clone(), + _update_diff: update_diff, + } + } + + fn update_visible_ranges(&mut self, cx: &mut Context) { + let ranges = self.excerpt_ranges(cx); + self.multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&self.buffer, cx), + self.buffer.clone(), + ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + let end = multibuffer.len(cx); + Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1) + }); + cx.notify(); + } + + fn excerpt_ranges(&self, cx: &App) -> Vec> { + let buffer = self.buffer.read(cx); + let diff = self.diff.read(cx); + let mut ranges = diff + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .collect::>(); + ranges.extend( + self.revealed_ranges + .iter() + .map(|range| range.to_point(&buffer)), + ); + ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); + + // Merge adjacent ranges + let mut ranges = ranges.into_iter().peekable(); + let mut merged_ranges = Vec::new(); + while let Some(mut range) = ranges.next() { + while let Some(next_range) = ranges.peek() { + if range.end >= next_range.start { + range.end = range.end.max(next_range.end); + ranges.next(); + } else { + break; + } + } + + merged_ranges.push(range); + } + merged_ranges + } +} + +pub struct FinalizedDiff { + path: PathBuf, + multibuffer: Entity, + _update_diff: Task>, +} + +async fn build_buffer_diff( + old_text: Arc, + buffer: &Entity, + language_registry: Option>, + cx: &mut AsyncApp, +) -> Result> { + let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; + + let old_text_rope = cx + .background_spawn({ + let old_text = old_text.clone(); + async move { Rope::from(old_text.as_str()) } + }) + .await; + let base_buffer = cx + .update(|cx| { + Buffer::build_snapshot( + old_text_rope, + buffer.language().cloned(), + language_registry, + cx, + ) + })? + .await; + + let diff_snapshot = cx + .update(|cx| { + BufferDiffSnapshot::new_with_base_buffer( + buffer.text.clone(), + Some(old_text), + base_buffer, + cx, + ) + })? + .await; + + let secondary_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer, cx); + diff.set_snapshot(diff_snapshot.clone(), &buffer, cx); + diff + })?; + + cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer.text, cx); + diff.set_snapshot(diff_snapshot, &buffer, cx); + diff.set_secondary_diff(secondary_diff); + diff + }) +} From 4d94b9c2c2c22f2019956a8238f7e7d9fc46e89e Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 19:53:44 -0300 Subject: [PATCH 11/22] agent2: Port read_file tool (text) --- Cargo.lock | 2 + crates/agent2/Cargo.toml | 3 + crates/agent2/src/agent.rs | 5 +- crates/agent2/src/thread.rs | 33 +- crates/agent2/src/tools.rs | 2 + crates/agent2/src/tools/read_file_tool.rs | 978 ++++++++++++++++++++++ 6 files changed, 1019 insertions(+), 4 deletions(-) create mode 100644 crates/agent2/src/tools/read_file_tool.rs diff --git a/Cargo.lock b/Cargo.lock index 8c1f1d00ba..517f9a2c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,10 +172,12 @@ dependencies = [ "gpui_tokio", "handlebars 4.5.0", "indoc", + "itertools 0.14.0", "language", "language_model", "language_models", "log", + "pretty_assertions", "project", "prompt_store", "reqwest_client", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 884378fbcc..a75011a671 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -24,6 +24,8 @@ futures.workspace = true gpui.workspace = true handlebars = { workspace = true, features = ["rust-embed"] } indoc.workspace = true +itertools.workspace = true +language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true @@ -55,3 +57,4 @@ project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } worktree = { workspace = true, "features" = ["test-support"] } +pretty_assertions.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index cb568f04c2..2014d86fb7 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,5 +1,5 @@ use crate::{templates::Templates, AgentResponseEvent, Thread}; -use crate::{FindPathTool, ThinkingTool, ToolCallAuthorization}; +use crate::{FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization}; use acp_thread::ModelSelector; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -413,9 +413,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { })?; let thread = cx.new(|_| { - let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log, agent.templates.clone(), default_model); + let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); + thread.add_tool(ReadFileTool::new(project.clone(), action_log)); thread }); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 805ffff1c0..4b8a65655f 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -125,7 +125,7 @@ pub struct Thread { project_context: Rc>, templates: Arc, pub selected_model: Arc, - _action_log: Entity, + action_log: Entity, } impl Thread { @@ -145,7 +145,7 @@ impl Thread { project_context, templates, selected_model: default_model, - _action_log: action_log, + action_log, } } @@ -315,6 +315,10 @@ impl Thread { events_rx } + pub fn action_log(&self) -> &Entity { + &self.action_log + } + pub fn build_system_message(&self) -> AgentMessage { log::debug!("Building system message"); let prompt = SystemPromptTemplate { @@ -924,3 +928,28 @@ impl ToolCallEventStream { .authorize_tool_call(&self.tool_use_id, title, kind, input) } } + +#[cfg(test)] +pub struct TestToolCallEventStream { + stream: ToolCallEventStream, + _events_rx: mpsc::UnboundedReceiver>, +} + +#[cfg(test)] +impl TestToolCallEventStream { + pub fn new() -> Self { + let (events_tx, events_rx) = + mpsc::unbounded::>(); + + let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx)); + + Self { + stream, + _events_rx: events_rx, + } + } + + pub fn stream(&self) -> ToolCallEventStream { + self.stream.clone() + } +} diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 848fe552ed..240614c263 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,5 +1,7 @@ mod find_path_tool; +mod read_file_tool; mod thinking_tool; pub use find_path_tool::*; +pub use read_file_tool::*; pub use thinking_tool::*; diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs new file mode 100644 index 0000000000..376252bbf6 --- /dev/null +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -0,0 +1,978 @@ +use agent_client_protocol::{self as acp}; +use anyhow::{anyhow, Result}; +use assistant_tool::{outline, ActionLog}; +use gpui::{Entity, Task}; +use indoc::formatdoc; +use language::{Anchor, Point}; +use project::{AgentLocation, Project, WorktreeSettings}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; +use std::sync::Arc; +use ui::{App, SharedString}; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Reads the content of the given file in the project. +/// +/// - Never attempt to read a path that hasn't been previously mentioned. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ReadFileToolInput { + /// The relative path of the file to read. + /// + /// This path should never be absolute, and the first component + /// of the path should always be a root directory in a project. + /// + /// + /// If the project has the following root directories: + /// + /// - /a/b/directory1 + /// - /c/d/directory2 + /// + /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`. + /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. + /// + pub path: String, + + /// Optional line number to start reading on (1-based index) + #[serde(default)] + pub start_line: Option, + + /// Optional line number to end reading on (1-based index, inclusive) + #[serde(default)] + pub end_line: Option, +} + +pub struct ReadFileTool { + project: Entity, + action_log: Entity, +} + +impl ReadFileTool { + pub fn new(project: Entity, action_log: Entity) -> Self { + Self { + project, + action_log, + } + } +} + +impl AgentTool for ReadFileTool { + type Input = ReadFileToolInput; + + fn name(&self) -> SharedString { + "read_file".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Read + } + + fn initial_title(&self, input: Self::Input) -> SharedString { + let path = &input.path; + match (input.start_line, input.end_line) { + (Some(start), Some(end)) => { + format!( + "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", + path, start, end, path, start, end + ) + } + (Some(start), None) => { + format!( + "[Read file `{}` (from line {})](@selection:{}:({}-{}))", + path, start, path, start, start + ) + } + _ => format!("[Read file `{}`](@file:{})", path, path), + } + .into() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { + return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into(); + }; + + // Error out if this path is either excluded or private in global settings + let global_settings = WorktreeSettings::get_global(cx); + if global_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if global_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the global `private_files` setting: {}", + &input.path + ))) + .into(); + } + + // Error out if this path is either excluded or private in worktree settings + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + if worktree_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if worktree_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the worktree `private_files` setting: {}", + &input.path + ))) + .into(); + } + + let file_path = input.path.clone(); + + event_stream.send_update(acp::ToolCallUpdateFields { + locations: Some(vec![acp::ToolCallLocation { + path: project_path.path.to_path_buf(), + line: input.start_line, + // TODO (tracked): use full range + }]), + ..Default::default() + }); + + // TODO (tracked): images + // if image_store::is_image_file(&self.project, &project_path, cx) { + // let model = &self.thread.read(cx).selected_model; + + // if !model.supports_images() { + // return Task::ready(Err(anyhow!( + // "Attempted to read an image, but Zed doesn't currently support sending images to {}.", + // model.name().0 + // ))) + // .into(); + // } + + // return cx.spawn(async move |cx| -> Result { + // let image_entity: Entity = cx + // .update(|cx| { + // self.project.update(cx, |project, cx| { + // project.open_image(project_path.clone(), cx) + // }) + // })? + // .await?; + + // let image = + // image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; + + // let language_model_image = cx + // .update(|cx| LanguageModelImage::from_image(image, cx))? + // .await + // .context("processing image")?; + + // Ok(ToolResultOutput { + // content: ToolResultContent::Image(language_model_image), + // output: None, + // }) + // }); + // } + // + + let project = self.project.clone(); + let action_log = self.action_log.clone(); + + cx.spawn(async move |cx| { + let buffer = cx + .update(|cx| { + project.update(cx, |project, cx| project.open_buffer(project_path, cx)) + })? + .await?; + if buffer.read_with(cx, |buffer, _| { + buffer + .file() + .as_ref() + .map_or(true, |file| !file.disk_state().exists()) + })? { + anyhow::bail!("{file_path} not found"); + } + + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: Anchor::MIN, + }), + cx, + ); + })?; + + // Check if specific line ranges are provided + if input.start_line.is_some() || input.end_line.is_some() { + let mut anchor = None; + let result = buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. + let start = input.start_line.unwrap_or(1).max(1); + let start_row = start - 1; + if start_row <= buffer.max_point().row { + let column = buffer.line_indent_for_row(start_row).raw_len(); + anchor = Some(buffer.anchor_before(Point::new(start_row, column))); + } + + let lines = text.split('\n').skip(start_row as usize); + if let Some(end) = input.end_line { + let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line + itertools::intersperse(lines.take(count as usize), "\n") + .collect::() + .into() + } else { + itertools::intersperse(lines, "\n") + .collect::() + .into() + } + })?; + + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + })?; + + if let Some(anchor) = anchor { + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: anchor, + }), + cx, + ); + })?; + } + + Ok(result) + } else { + // No line ranges specified, so check file size to see if it's too big. + let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; + + if file_size <= outline::AUTO_OUTLINE_SIZE { + // File is small enough, so return its contents. + let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?; + + action_log.update(cx, |log, cx| { + log.buffer_read(buffer, cx); + })?; + + Ok(result.into()) + } else { + // File is too big, so return the outline + // and a suggestion to read again with line numbers. + let outline = + outline::file_outline(project, file_path, action_log, None, cx).await?; + Ok(formatdoc! {" + This file was too big to read all at once. + + Here is an outline of its symbols: + + {outline} + + Using the line numbers in this outline, you can call this tool again + while specifying the start_line and end_line fields to see the + implementations of symbols in the outline. + + Alternatively, you can fall back to the `grep` tool (if available) + to search the file for specific content." + }) + } + } + }) + } +} + +#[cfg(test)] +mod test { + use crate::TestToolCallEventStream; + + use super::*; + use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + #[gpui::test] + async fn test_read_nonexistent_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/nonexistent_file.txt".to_string(), + start_line: None, + end_line: None, + }; + tool.run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "root/nonexistent_file.txt not found" + ); + } + #[gpui::test] + async fn test_read_small_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "small_file.txt": "This is a small file content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/small_file.txt".into(), + start_line: None, + end_line: None, + }; + tool.run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!(result.unwrap(), "This is a small file content"); + } + + #[gpui::test] + async fn test_read_large_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(rust_lang())); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + let content = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/large_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await + .unwrap(); + + assert_eq!( + content.lines().skip(4).take(6).collect::>(), + vec![ + "struct Test0 [L1-4]", + " a [L2]", + " b [L3]", + "struct Test1 [L5-8]", + " a [L6]", + " b [L7]", + ] + ); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/large_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.run(input, event_stream.stream(), cx) + }) + .await; + let content = result.unwrap(); + let expected_content = (0..1000) + .flat_map(|i| { + vec![ + format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4), + format!(" a [L{}]", i * 4 + 2), + format!(" b [L{}]", i * 4 + 3), + ] + }) + .collect::>(); + pretty_assertions::assert_eq!( + content + .lines() + .skip(4) + .take(expected_content.len()) + .collect::>(), + expected_content + ); + } + + #[gpui::test] + async fn test_read_file_with_line_range(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/multiline.txt".to_string(), + start_line: Some(2), + end_line: Some(4), + }; + tool.run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4"); + } + + #[gpui::test] + async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + + // start_line of 0 should be treated as 1 + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/multiline.txt".to_string(), + start_line: Some(0), + end_line: Some(2), + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!(result.unwrap(), "Line 1\nLine 2"); + + // end_line of 0 should result in at least 1 line + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/multiline.txt".to_string(), + start_line: Some(1), + end_line: Some(0), + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!(result.unwrap(), "Line 1"); + + // when start_line > end_line, should still return at least 1 line + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/multiline.txt".to_string(), + start_line: Some(3), + end_line: Some(2), + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert_eq!(result.unwrap(), "Line 3"); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_outline_query( + r#" + (line_comment) @annotation + + (struct_item + "struct" @context + name: (_) @name) @item + (enum_item + "enum" @context + name: (_) @name) @item + (enum_variant + name: (_) @name) @item + (field_declaration + name: (_) @name) @item + (impl_item + "impl" @context + trait: (_)? @name + "for"? @context + type: (_) @name + body: (_ "{" (_)* "}")) @item + (function_item + "fn" @context + name: (_) @name) @item + (mod_item + "mod" @context + name: (_) @name) @item + "#, + ) + .unwrap() + } + + #[gpui::test] + async fn test_read_file_security(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + path!("/"), + json!({ + "project_root": { + "allowed_file.txt": "This file is in the project", + ".mysecrets": "SECRET_KEY=abc123", + ".secretdir": { + "config": "special configuration" + }, + ".mymetadata": "custom metadata", + "subdir": { + "normal_file.txt": "Normal file content", + "special.privatekey": "private key content", + "data.mysensitive": "sensitive data" + } + }, + "outside_project": { + "sensitive_file.txt": "This file is outside the project" + } + }), + ) + .await; + + cx.update(|cx| { + use gpui::UpdateGlobal; + use project::WorktreeSettings; + use settings::SettingsStore; + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log)); + let event_stream = TestToolCallEventStream::new(); + + // Reading a file outside the project worktree should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "/outside_project/sensitive_file.txt".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read an absolute path outside a worktree" + ); + + // Reading a file within the project should succeed + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/allowed_file.txt".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_ok(), + "read_file_tool should be able to read files inside worktrees" + ); + + // Reading files that match file_scan_exclusions should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/.secretdir/config".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)" + ); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/.mymetadata".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)" + ); + + // Reading private files should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/.mysecrets".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mysecrets (private_files)" + ); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/subdir/special.privatekey".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .privatekey files (private_files)" + ); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/subdir/data.mysensitive".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mysensitive files (private_files)" + ); + + // Reading a normal file should still work, even with private_files configured + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/subdir/normal_file.txt".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + assert!(result.is_ok(), "Should be able to read normal files"); + assert_eq!(result.unwrap(), "Normal file content"); + + // Path traversal attempts with .. should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "project_root/../outside_project/sensitive_file.txt".to_string(), + start_line: None, + end_line: None, + }; + tool.run(input, event_stream.stream(), cx) + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree" + ); + } + + #[gpui::test] + async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private_files setting + fs.insert_tree( + path!("/worktree1"), + json!({ + "src": { + "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", + "secret.rs": "const API_KEY: &str = \"secret_key_1\";", + "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" + }, + "tests": { + "test.rs": "mod tests { fn test_it() {} }", + "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" + }, + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs", "**/config.toml"] + }"# + } + }), + ) + .await; + + // Create second worktree with different private_files setting + fs.insert_tree( + path!("/worktree2"), + json!({ + "lib": { + "public.js": "export function greet() { return 'Hello from worktree2'; }", + "private.js": "const SECRET_TOKEN = \"private_token_2\";", + "data.json": "{\"api_key\": \"json_secret_key\"}" + }, + "docs": { + "README.md": "# Public Documentation", + "internal.md": "# Internal Secrets and Configuration" + }, + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone())); + let event_stream = TestToolCallEventStream::new(); + + // Test reading allowed files in worktree1 + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree1/src/main.rs".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await + .unwrap(); + + assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }"); + + // Test reading private file in worktree1 should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree1/src/secret.rs".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Error should mention worktree private_files setting" + ); + + // Test reading excluded file in worktree1 should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree1/tests/fixture.sql".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `file_scan_exclusions` setting"), + "Error should mention worktree file_scan_exclusions setting" + ); + + // Test reading allowed files in worktree2 + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree2/lib/public.js".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await + .unwrap(); + + assert_eq!( + result, + "export function greet() { return 'Hello from worktree2'; }" + ); + + // Test reading private file in worktree2 should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree2/lib/private.js".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Error should mention worktree private_files setting" + ); + + // Test reading excluded file in worktree2 should fail + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree2/docs/internal.md".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `file_scan_exclusions` setting"), + "Error should mention worktree file_scan_exclusions setting" + ); + + // Test that files allowed in one worktree but not in another are handled correctly + // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2) + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "worktree1/src/config.toml".to_string(), + start_line: None, + end_line: None, + }; + tool.clone().run(input, event_stream.stream(), cx) + }) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Config.toml should be blocked by worktree1's private_files setting" + ); + } +} From 5bfe086bf47e0e9fd791ad71a4a202ba4a1d58af Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 19:55:43 -0300 Subject: [PATCH 12/22] Remove needless into --- crates/agent2/src/tools/read_file_tool.rs | 24 ++++++++--------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 376252bbf6..30794ccdad 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -95,7 +95,7 @@ impl AgentTool for ReadFileTool { cx: &mut App, ) -> Task> { let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { - return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into(); + return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))); }; // Error out if this path is either excluded or private in global settings @@ -104,16 +104,14 @@ impl AgentTool for ReadFileTool { return Task::ready(Err(anyhow!( "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}", &input.path - ))) - .into(); + ))); } if global_settings.is_path_private(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot read file because its path matches the global `private_files` setting: {}", &input.path - ))) - .into(); + ))); } // Error out if this path is either excluded or private in worktree settings @@ -122,16 +120,14 @@ impl AgentTool for ReadFileTool { return Task::ready(Err(anyhow!( "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}", &input.path - ))) - .into(); + ))); } if worktree_settings.is_path_private(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot read file because its path matches the worktree `private_files` setting: {}", &input.path - ))) - .into(); + ))); } let file_path = input.path.clone(); @@ -226,13 +222,9 @@ impl AgentTool for ReadFileTool { let lines = text.split('\n').skip(start_row as usize); if let Some(end) = input.end_line { let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line - itertools::intersperse(lines.take(count as usize), "\n") - .collect::() - .into() + itertools::intersperse(lines.take(count as usize), "\n").collect::() } else { - itertools::intersperse(lines, "\n") - .collect::() - .into() + itertools::intersperse(lines, "\n").collect::() } })?; @@ -265,7 +257,7 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer, cx); })?; - Ok(result.into()) + Ok(result) } else { // File is too big, so return the outline // and a suggestion to read again with line numbers. From dbba5c89676e0c4dc77c4354c5ee6814d66d6487 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 Aug 2025 18:15:08 -0300 Subject: [PATCH 13/22] Port edit tool logic --- Cargo.lock | 3 + crates/agent2/Cargo.toml | 4 + crates/agent2/src/agent.rs | 7 +- crates/agent2/src/thread.rs | 29 +- crates/agent2/src/tools.rs | 2 + crates/agent2/src/tools/edit_file_tool.rs | 1588 +++++++++++++++++ crates/assistant_tools/src/assistant_tools.rs | 4 +- 7 files changed, 1629 insertions(+), 8 deletions(-) create mode 100644 crates/agent2/src/tools/edit_file_tool.rs diff --git a/Cargo.lock b/Cargo.lock index a09459ddb6..59477b4d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,8 +158,10 @@ dependencies = [ "acp_thread", "agent-client-protocol", "agent_servers", + "agent_settings", "anyhow", "assistant_tool", + "assistant_tools", "client", "clock", "cloud_llm_client", @@ -176,6 +178,7 @@ dependencies = [ "language_model", "language_models", "log", + "paths", "project", "prompt_store", "reqwest_client", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 884378fbcc..062ba079ba 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -14,9 +14,11 @@ workspace = true [dependencies] acp_thread.workspace = true agent-client-protocol.workspace = true +agent_settings.workspace = true agent_servers.workspace = true anyhow.workspace = true assistant_tool.workspace = true +assistant_tools.workspace = true cloud_llm_client.workspace = true collections.workspace = true fs.workspace = true @@ -24,9 +26,11 @@ futures.workspace = true gpui.workspace = true handlebars = { workspace = true, features = ["rust-embed"] } indoc.workspace = true +language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +paths.workspace = true project.workspace = true prompt_store.workspace = true rust-embed.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 4583688ad3..fe91dc7d0f 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -1,5 +1,5 @@ use crate::{templates::Templates, AgentResponseEvent, Thread}; -use crate::{FindPathTool, ToolCallAuthorization}; +use crate::{EditFileTool, FindPathTool, ToolCallAuthorization}; use acp_thread::ModelSelector; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -412,9 +412,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { anyhow!("No default model configured. Please configure a default model in settings.") })?; - let thread = cx.new(|_| { - let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log, agent.templates.clone(), default_model); + let thread = cx.new(|cx| { + let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model); thread.add_tool(FindPathTool::new(project.clone())); + thread.add_tool(EditFileTool::new(project.clone(), cx.entity())); thread }); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 9be2860eec..1b7751bf9a 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,4 +1,5 @@ use crate::templates::{SystemPromptTemplate, Template, Templates}; +use acp_thread::Diff; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; use assistant_tool::ActionLog; @@ -132,7 +133,7 @@ pub struct Thread { project_context: Rc>, templates: Arc, pub selected_model: Arc, - _action_log: Entity, + action_log: Entity, } impl Thread { @@ -152,7 +153,7 @@ impl Thread { project_context, templates, selected_model: default_model, - _action_log: action_log, + action_log, } } @@ -322,6 +323,10 @@ impl Thread { events_rx } + pub fn action_log(&self) -> &Entity { + &self.action_log + } + pub fn build_system_message(&self) -> AgentMessage { log::debug!("Building system message"); let prompt = SystemPromptTemplate { @@ -538,6 +543,11 @@ impl Thread { status: Some(acp::ToolCallStatus::InProgress), ..Default::default() }); + log::trace!( + "Running tool {:?} with input: {}", + tool_use.name, + serde_json::to_string_pretty(&tool_use.input).unwrap_or_default() + ); cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))? .await }) @@ -586,7 +596,7 @@ impl Thread { self.messages.last_mut().unwrap() } - fn build_completion_request( + pub(crate) fn build_completion_request( &self, completion_intent: CompletionIntent, cx: &mut App, @@ -866,6 +876,12 @@ impl AgentResponseEventStream { .ok(); } + fn send_tool_call_diff(&self, tool_call_diff: ToolCallDiff) { + self.0 + .unbounded_send(Ok(AgentResponseEvent::ToolCallDiff(tool_call_diff))) + .ok(); + } + fn send_stop(&self, reason: StopReason) { match reason { StopReason::EndTurn => { @@ -909,4 +925,11 @@ impl ToolCallEventStream { pub fn send_update(&self, fields: acp::ToolCallUpdateFields) { self.stream.send_tool_call_update(&self.tool_use_id, fields); } + + pub fn send_diff(&self, diff: Entity) { + self.stream.send_tool_call_diff(ToolCallDiff { + tool_call_id: acp::ToolCallId(self.tool_use_id.to_string().into()), + diff, + }); + } } diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs index 906992fc52..10e0a22ec5 100644 --- a/crates/agent2/src/tools.rs +++ b/crates/agent2/src/tools.rs @@ -1,3 +1,5 @@ +mod edit_file_tool; mod find_path_tool; +pub use edit_file_tool::*; pub use find_path_tool::*; diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs new file mode 100644 index 0000000000..10edba703e --- /dev/null +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -0,0 +1,1588 @@ +use acp_thread::Diff; +use agent_client_protocol as acp; +use anyhow::{anyhow, Context as _, Result}; +use assistant_tools::edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat}; +use cloud_llm_client::CompletionIntent; +use collections::HashSet; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; +use indoc::formatdoc; +use language::language_settings::{self, FormatOnSave}; +use paths; +use project::lsp_store::{FormatTrigger, LspFormatTarget}; +use project::{Project, ProjectPath}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings as _; +use smol::stream::StreamExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use ui::SharedString; +use util::ResultExt; + +use crate::{AgentTool, Thread, ToolCallEventStream}; + +/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. +/// +/// Before using this tool: +/// +/// 1. Use the `read_file` tool to understand the file's contents and context +/// +/// 2. Verify the directory path is correct (only applicable when creating new files): +/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct EditFileToolInput { + /// A one-line, user-friendly markdown description of the edit. This will be + /// shown in the UI and also passed to another model to perform the edit. + /// + /// Be terse, but also descriptive in what you want to achieve with this + /// edit. Avoid generic instructions. + /// + /// NEVER mention the file path in this description. + /// + /// Fix API endpoint URLs + /// Update copyright year in `page_footer` + /// + /// Make sure to include this field before all the others in the input object + /// so that we can display it immediately. + pub display_description: String, + + /// The full path of the file to create or modify in the project. + /// + /// WARNING: When specifying which file path need changing, you MUST + /// start each path with one of the project's root directories. + /// + /// The following examples assume we have two root directories in the project: + /// - /a/b/backend + /// - /c/d/frontend + /// + /// + /// `backend/src/main.rs` + /// + /// Notice how the file path starts with `backend`. Without that, the path + /// would be ambiguous and the call would fail! + /// + /// + /// + /// `frontend/db.js` + /// + pub path: PathBuf, + + /// The mode of operation on the file. Possible values: + /// - 'edit': Make granular edits to an existing file. + /// - 'create': Create a new file if it doesn't exist. + /// - 'overwrite': Replace the entire contents of an existing file. + /// + /// When a file already exists or you just created it, prefer editing + /// it as opposed to recreating it from scratch. + pub mode: EditFileMode, + + /// The new content for the file (required for create and overwrite modes) + /// For edit mode, this field is not used - edits happen through the edit agent + #[serde(default)] + pub content: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum EditFileMode { + Edit, + Create, + Overwrite, +} + +pub struct EditFileTool { + project: Entity, + thread: Entity, +} + +impl EditFileTool { + pub fn new(project: Entity, thread: Entity) -> Self { + Self { project, thread } + } +} + +impl AgentTool for EditFileTool { + type Input = EditFileToolInput; + + fn name(&self) -> SharedString { + "edit_file".into() + } + + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Edit + } + + fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return false; + } + + // If any path component matches the local settings folder, then this could affect + // the editor in ways beyond the project source, so prompt. + let local_settings_folder = paths::local_settings_folder_relative_path(); + let path = Path::new(&input.path); + if path + .components() + .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) + { + return true; + } + + // It's also possible that the global config dir is configured to be inside the project, + // so check for that edge case too. + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + return true; + } + } + + // Check if path is inside the global config directory + // First check if it's already inside project - if not, try to canonicalize + let project_path = self.project.read(cx).find_project_path(&input.path, cx); + + // If the path is inside the project, and it's not one of the above edge cases, + // then no confirmation is necessary. Otherwise, confirmation is necessary. + project_path.is_none() + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project_path = match resolve_path(&input, self.project.clone(), cx) { + Ok(path) => path, + Err(err) => return Task::ready(Err(anyhow!(err))).into(), + }; + + let project = self.project.clone(); + let request = self.thread.update(cx, |thread, cx| { + thread.build_completion_request(CompletionIntent::ToolResults, cx) + }); + let thread = self.thread.read(cx); + let model = thread.selected_model.clone(); + let action_log = thread.action_log().clone(); + + cx.spawn(async move |cx: &mut AsyncApp| { + let edit_format = EditFormat::from_model(model.clone())?; + let edit_agent = EditAgent::new( + model, + project.clone(), + action_log.clone(), + // todo! move edit agent to this crate so we can use our templates? + assistant_tools::templates::Templates::new(), + edit_format, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + })? + .await?; + + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; + event_stream.send_diff(diff.clone()); + + let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + let old_text = cx + .background_spawn({ + let old_snapshot = old_snapshot.clone(); + async move { Arc::new(old_snapshot.text()) } + }) + .await; + + + let mut events = if matches!(input.mode, EditFileMode::Edit) { + edit_agent.edit( + buffer.clone(), + input.display_description.clone(), + &request, + cx, + ).1 + } else { + edit_agent.overwrite( + buffer.clone(), + input.display_description.clone(), + &request, + cx, + ).1 + }; + + let mut hallucinated_old_text = false; + let mut ambiguous_ranges = Vec::new(); + while let Some(event) = events.next().await { + match event { + EditAgentOutputEvent::Edited => {}, + EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, + EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, + EditAgentOutputEvent::ResolvingEditRange(range) => { + diff.update(cx, |card, cx| card.reveal_range(range, cx))?; + } + } + } + + // If format_on_save is enabled, format the buffer + let format_on_save_enabled = buffer + .read_with(cx, |buffer, cx| { + let settings = language_settings::language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ); + settings.format_on_save != FormatOnSave::Off + }) + .unwrap_or(false); + + if format_on_save_enabled { + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + })?; + + let format_task = project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, // Don't push to history since the tool did it. + FormatTrigger::Save, + cx, + ) + })?; + format_task.await.log_err(); + } + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + })?; + + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + let unified_diff = cx + .background_spawn({ + let new_snapshot = new_snapshot.clone(); + let old_text = old_text.clone(); + async move { + let new_text = new_snapshot.text(); + language::unified_diff(&old_text, &new_text) + } + }) + .await; + + diff.update(cx, |diff, cx| { + diff.finalize(cx); + }).ok(); + + let input_path = input.path.display(); + if unified_diff.is_empty() { + anyhow::ensure!( + !hallucinated_old_text, + formatdoc! {" + Some edits were produced but none of them could be applied. + Read the relevant sections of {input_path} again so that + I can perform the requested edits. + "} + ); + anyhow::ensure!( + ambiguous_ranges.is_empty(), + { + let line_numbers = ambiguous_ranges + .iter() + .map(|range| range.start.to_string()) + .collect::>() + .join(", "); + formatdoc! {" + matches more than one position in the file (lines: {line_numbers}). Read the + relevant sections of {input_path} again and extend so + that I can perform the requested edits. + "} + } + ); + Ok("No edits were made.".into()) + } else { + Ok(format!( + "Edited {}:\n\n```diff\n{}\n```", + input_path, unified_diff + )) + } + }) + } +} + +/// Validate that the file path is valid, meaning: +/// +/// - For `edit` and `overwrite`, the path must point to an existing file. +/// - For `create`, the file must not already exist, but it's parent dir must exist. +fn resolve_path( + input: &EditFileToolInput, + project: Entity, + cx: &mut App, +) -> Result { + let project = project.read(cx); + + match input.mode { + EditFileMode::Edit | EditFileMode::Overwrite => { + let path = project + .find_project_path(&input.path, cx) + .context("Can't edit file: path not found")?; + + let entry = project + .entry_for_path(&path, cx) + .context("Can't edit file: path not found")?; + + anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); + Ok(path) + } + + EditFileMode::Create => { + if let Some(path) = project.find_project_path(&input.path, cx) { + anyhow::ensure!( + project.entry_for_path(&path, cx).is_none(), + "Can't create file: file already exists" + ); + } + + let parent_path = input + .path + .parent() + .context("Can't create file: incorrect path")?; + + let parent_project_path = project.find_project_path(&parent_path, cx); + + let parent_entry = parent_project_path + .as_ref() + .and_then(|path| project.entry_for_path(&path, cx)) + .context("Can't create file: parent directory doesn't exist")?; + + anyhow::ensure!( + parent_entry.is_dir(), + "Can't create file: parent is not a directory" + ); + + let file_name = input + .path + .file_name() + .context("Can't create file: invalid filename")?; + + let new_file_path = parent_project_path.map(|parent| ProjectPath { + path: Arc::from(parent.path.join(file_name)), + ..parent + }); + + new_file_path.context("Can't create file") + } + } +} + +// todo! restore tests +// #[cfg(test)] +// mod tests { +// use super::*; +// use ::fs::Fs; +// use client::TelemetrySettings; +// use gpui::{TestAppContext, UpdateGlobal}; +// use language_model::fake_provider::FakeLanguageModel; +// use serde_json::json; +// use settings::SettingsStore; +// use std::fs; +// use util::path; + +// #[gpui::test] +// async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { +// init_test(cx); + +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/root", json!({})).await; +// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; +// let action_log = cx.new(|_| ActionLog::new(project.clone())); +// let model = Arc::new(FakeLanguageModel::default()); +// let result = cx +// .update(|cx| { +// let input = serde_json::to_value(EditFileToolInput { +// display_description: "Some edit".into(), +// path: "root/nonexistent_file.txt".into(), +// mode: EditFileMode::Edit, +// }) +// .unwrap(); +// Arc::new(EditFileTool) +// .run( +// input, +// Arc::default(), +// project.clone(), +// action_log, +// model, +// None, +// cx, +// ) +// .output +// }) +// .await; +// assert_eq!( +// result.unwrap_err().to_string(), +// "Can't edit file: path not found" +// ); +// } + +// #[gpui::test] +// async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { +// let mode = &EditFileMode::Create; + +// let result = test_resolve_path(mode, "root/new.txt", cx); +// assert_resolved_path_eq(result.await, "new.txt"); + +// let result = test_resolve_path(mode, "new.txt", cx); +// assert_resolved_path_eq(result.await, "new.txt"); + +// let result = test_resolve_path(mode, "dir/new.txt", cx); +// assert_resolved_path_eq(result.await, "dir/new.txt"); + +// let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); +// assert_eq!( +// result.await.unwrap_err().to_string(), +// "Can't create file: file already exists" +// ); + +// let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); +// assert_eq!( +// result.await.unwrap_err().to_string(), +// "Can't create file: parent directory doesn't exist" +// ); +// } + +// #[gpui::test] +// async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { +// let mode = &EditFileMode::Edit; + +// let path_with_root = "root/dir/subdir/existing.txt"; +// let path_without_root = "dir/subdir/existing.txt"; +// let result = test_resolve_path(mode, path_with_root, cx); +// assert_resolved_path_eq(result.await, path_without_root); + +// let result = test_resolve_path(mode, path_without_root, cx); +// assert_resolved_path_eq(result.await, path_without_root); + +// let result = test_resolve_path(mode, "root/nonexistent.txt", cx); +// assert_eq!( +// result.await.unwrap_err().to_string(), +// "Can't edit file: path not found" +// ); + +// let result = test_resolve_path(mode, "root/dir", cx); +// assert_eq!( +// result.await.unwrap_err().to_string(), +// "Can't edit file: path is a directory" +// ); +// } + +// async fn test_resolve_path( +// mode: &EditFileMode, +// path: &str, +// cx: &mut TestAppContext, +// ) -> anyhow::Result { +// init_test(cx); + +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/root", +// json!({ +// "dir": { +// "subdir": { +// "existing.txt": "hello" +// } +// } +// }), +// ) +// .await; +// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + +// let input = EditFileToolInput { +// display_description: "Some edit".into(), +// path: path.into(), +// mode: mode.clone(), +// }; + +// let result = cx.update(|cx| resolve_path(&input, project, cx)); +// result +// } + +// fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { +// let actual = path +// .expect("Should return valid path") +// .path +// .to_str() +// .unwrap() +// .replace("\\", "/"); // Naive Windows paths normalization +// assert_eq!(actual, expected); +// } + +// #[test] +// fn still_streaming_ui_text_with_path() { +// let input = json!({ +// "path": "src/main.rs", +// "display_description": "", +// "old_string": "old code", +// "new_string": "new code" +// }); + +// assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); +// } + +// #[test] +// fn still_streaming_ui_text_with_description() { +// let input = json!({ +// "path": "", +// "display_description": "Fix error handling", +// "old_string": "old code", +// "new_string": "new code" +// }); + +// assert_eq!( +// EditFileTool.still_streaming_ui_text(&input), +// "Fix error handling", +// ); +// } + +// #[test] +// fn still_streaming_ui_text_with_path_and_description() { +// let input = json!({ +// "path": "src/main.rs", +// "display_description": "Fix error handling", +// "old_string": "old code", +// "new_string": "new code" +// }); + +// assert_eq!( +// EditFileTool.still_streaming_ui_text(&input), +// "Fix error handling", +// ); +// } + +// #[test] +// fn still_streaming_ui_text_no_path_or_description() { +// let input = json!({ +// "path": "", +// "display_description": "", +// "old_string": "old code", +// "new_string": "new code" +// }); + +// assert_eq!( +// EditFileTool.still_streaming_ui_text(&input), +// DEFAULT_UI_TEXT, +// ); +// } + +// #[test] +// fn still_streaming_ui_text_with_null() { +// let input = serde_json::Value::Null; + +// assert_eq!( +// EditFileTool.still_streaming_ui_text(&input), +// DEFAULT_UI_TEXT, +// ); +// } + +// fn init_test(cx: &mut TestAppContext) { +// cx.update(|cx| { +// let settings_store = SettingsStore::test(cx); +// cx.set_global(settings_store); +// language::init(cx); +// TelemetrySettings::register(cx); +// agent_settings::AgentSettings::register(cx); +// Project::init_settings(cx); +// }); +// } + +// fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { +// cx.update(|cx| { +// // Set custom data directory (config will be under data_dir/config) +// paths::set_custom_data_dir(data_dir.to_str().unwrap()); + +// let settings_store = SettingsStore::test(cx); +// cx.set_global(settings_store); +// language::init(cx); +// TelemetrySettings::register(cx); +// agent_settings::AgentSettings::register(cx); +// Project::init_settings(cx); +// }); +// } + +// #[gpui::test] +// async fn test_format_on_save(cx: &mut TestAppContext) { +// init_test(cx); + +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/root", json!({"src": {}})).await; + +// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + +// // Set up a Rust language with LSP formatting support +// let rust_language = Arc::new(language::Language::new( +// language::LanguageConfig { +// name: "Rust".into(), +// matcher: language::LanguageMatcher { +// path_suffixes: vec!["rs".to_string()], +// ..Default::default() +// }, +// ..Default::default() +// }, +// None, +// )); + +// // Register the language and fake LSP +// let language_registry = project.read_with(cx, |project, _| project.languages().clone()); +// language_registry.add(rust_language); + +// let mut fake_language_servers = language_registry.register_fake_lsp( +// "Rust", +// language::FakeLspAdapter { +// capabilities: lsp::ServerCapabilities { +// document_formatting_provider: Some(lsp::OneOf::Left(true)), +// ..Default::default() +// }, +// ..Default::default() +// }, +// ); + +// // Create the file +// fs.save( +// path!("/root/src/main.rs").as_ref(), +// &"initial content".into(), +// language::LineEnding::Unix, +// ) +// .await +// .unwrap(); + +// // Open the buffer to trigger LSP initialization +// let buffer = project +// .update(cx, |project, cx| { +// project.open_local_buffer(path!("/root/src/main.rs"), cx) +// }) +// .await +// .unwrap(); + +// // Register the buffer with language servers +// let _handle = project.update(cx, |project, cx| { +// project.register_buffer_with_language_servers(&buffer, cx) +// }); + +// const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; +// const FORMATTED_CONTENT: &str = +// "This file was formatted by the fake formatter in the test.\n"; + +// // Get the fake language server and set up formatting handler +// let fake_language_server = fake_language_servers.next().await.unwrap(); +// fake_language_server.set_request_handler::({ +// |_, _| async move { +// Ok(Some(vec![lsp::TextEdit { +// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), +// new_text: FORMATTED_CONTENT.to_string(), +// }])) +// } +// }); + +// let action_log = cx.new(|_| ActionLog::new(project.clone())); +// let model = Arc::new(FakeLanguageModel::default()); + +// // First, test with format_on_save enabled +// cx.update(|cx| { +// SettingsStore::update_global(cx, |store, cx| { +// store.update_user_settings::( +// cx, +// |settings| { +// settings.defaults.format_on_save = Some(FormatOnSave::On); +// settings.defaults.formatter = +// Some(language::language_settings::SelectedFormatter::Auto); +// }, +// ); +// }); +// }); + +// // Have the model stream unformatted content +// let edit_result = { +// let edit_task = cx.update(|cx| { +// let input = serde_json::to_value(EditFileToolInput { +// display_description: "Create main function".into(), +// path: "root/src/main.rs".into(), +// mode: EditFileMode::Overwrite, +// }) +// .unwrap(); +// Arc::new(EditFileTool) +// .run( +// input, +// Arc::default(), +// project.clone(), +// action_log.clone(), +// model.clone(), +// None, +// cx, +// ) +// .output +// }); + +// // Stream the unformatted content +// cx.executor().run_until_parked(); +// model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); +// model.end_last_completion_stream(); + +// edit_task.await +// }; +// assert!(edit_result.is_ok()); + +// // Wait for any async operations (e.g. formatting) to complete +// cx.executor().run_until_parked(); + +// // Read the file to verify it was formatted automatically +// let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); +// assert_eq!( +// // Ignore carriage returns on Windows +// new_content.replace("\r\n", "\n"), +// FORMATTED_CONTENT, +// "Code should be formatted when format_on_save is enabled" +// ); + +// let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); + +// assert_eq!( +// stale_buffer_count, 0, +// "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ +// This causes the agent to think the file was modified externally when it was just formatted.", +// stale_buffer_count +// ); + +// // Next, test with format_on_save disabled +// cx.update(|cx| { +// SettingsStore::update_global(cx, |store, cx| { +// store.update_user_settings::( +// cx, +// |settings| { +// settings.defaults.format_on_save = Some(FormatOnSave::Off); +// }, +// ); +// }); +// }); + +// // Stream unformatted edits again +// let edit_result = { +// let edit_task = cx.update(|cx| { +// let input = serde_json::to_value(EditFileToolInput { +// display_description: "Update main function".into(), +// path: "root/src/main.rs".into(), +// mode: EditFileMode::Overwrite, +// }) +// .unwrap(); +// Arc::new(EditFileTool) +// .run( +// input, +// Arc::default(), +// project.clone(), +// action_log.clone(), +// model.clone(), +// None, +// cx, +// ) +// .output +// }); + +// // Stream the unformatted content +// cx.executor().run_until_parked(); +// model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); +// model.end_last_completion_stream(); + +// edit_task.await +// }; +// assert!(edit_result.is_ok()); + +// // Wait for any async operations (e.g. formatting) to complete +// cx.executor().run_until_parked(); + +// // Verify the file was not formatted +// let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); +// assert_eq!( +// // Ignore carriage returns on Windows +// new_content.replace("\r\n", "\n"), +// UNFORMATTED_CONTENT, +// "Code should not be formatted when format_on_save is disabled" +// ); +// } + +// #[gpui::test] +// async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { +// init_test(cx); + +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/root", json!({"src": {}})).await; + +// // Create a simple file with trailing whitespace +// fs.save( +// path!("/root/src/main.rs").as_ref(), +// &"initial content".into(), +// language::LineEnding::Unix, +// ) +// .await +// .unwrap(); + +// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; +// let action_log = cx.new(|_| ActionLog::new(project.clone())); +// let model = Arc::new(FakeLanguageModel::default()); + +// // First, test with remove_trailing_whitespace_on_save enabled +// cx.update(|cx| { +// SettingsStore::update_global(cx, |store, cx| { +// store.update_user_settings::( +// cx, +// |settings| { +// settings.defaults.remove_trailing_whitespace_on_save = Some(true); +// }, +// ); +// }); +// }); + +// const CONTENT_WITH_TRAILING_WHITESPACE: &str = +// "fn main() { \n println!(\"Hello!\"); \n}\n"; + +// // Have the model stream content that contains trailing whitespace +// let edit_result = { +// let edit_task = cx.update(|cx| { +// let input = serde_json::to_value(EditFileToolInput { +// display_description: "Create main function".into(), +// path: "root/src/main.rs".into(), +// mode: EditFileMode::Overwrite, +// }) +// .unwrap(); +// Arc::new(EditFileTool) +// .run( +// input, +// Arc::default(), +// project.clone(), +// action_log.clone(), +// model.clone(), +// None, +// cx, +// ) +// .output +// }); + +// // Stream the content with trailing whitespace +// cx.executor().run_until_parked(); +// model.send_last_completion_stream_text_chunk( +// CONTENT_WITH_TRAILING_WHITESPACE.to_string(), +// ); +// model.end_last_completion_stream(); + +// edit_task.await +// }; +// assert!(edit_result.is_ok()); + +// // Wait for any async operations (e.g. formatting) to complete +// cx.executor().run_until_parked(); + +// // Read the file to verify trailing whitespace was removed automatically +// assert_eq!( +// // Ignore carriage returns on Windows +// fs.load(path!("/root/src/main.rs").as_ref()) +// .await +// .unwrap() +// .replace("\r\n", "\n"), +// "fn main() {\n println!(\"Hello!\");\n}\n", +// "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" +// ); + +// // Next, test with remove_trailing_whitespace_on_save disabled +// cx.update(|cx| { +// SettingsStore::update_global(cx, |store, cx| { +// store.update_user_settings::( +// cx, +// |settings| { +// settings.defaults.remove_trailing_whitespace_on_save = Some(false); +// }, +// ); +// }); +// }); + +// // Stream edits again with trailing whitespace +// let edit_result = { +// let edit_task = cx.update(|cx| { +// let input = serde_json::to_value(EditFileToolInput { +// display_description: "Update main function".into(), +// path: "root/src/main.rs".into(), +// mode: EditFileMode::Overwrite, +// }) +// .unwrap(); +// Arc::new(EditFileTool) +// .run( +// input, +// Arc::default(), +// project.clone(), +// action_log.clone(), +// model.clone(), +// None, +// cx, +// ) +// .output +// }); + +// // Stream the content with trailing whitespace +// cx.executor().run_until_parked(); +// model.send_last_completion_stream_text_chunk( +// CONTENT_WITH_TRAILING_WHITESPACE.to_string(), +// ); +// model.end_last_completion_stream(); + +// edit_task.await +// }; +// assert!(edit_result.is_ok()); + +// // Wait for any async operations (e.g. formatting) to complete +// cx.executor().run_until_parked(); + +// // Verify the file still has trailing whitespace +// // Read the file again - it should still have trailing whitespace +// let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); +// assert_eq!( +// // Ignore carriage returns on Windows +// final_content.replace("\r\n", "\n"), +// CONTENT_WITH_TRAILING_WHITESPACE, +// "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" +// ); +// } + +// #[gpui::test] +// async fn test_needs_confirmation(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/root", json!({})).await; + +// // Test 1: Path with .zed component should require confirmation +// let input_with_zed = json!({ +// "display_description": "Edit settings", +// "path": ".zed/settings.json", +// "mode": "edit" +// }); +// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_with_zed, &project, cx), +// "Path with .zed component should require confirmation" +// ); +// }); + +// // Test 2: Absolute path should require confirmation +// let input_absolute = json!({ +// "display_description": "Edit file", +// "path": "/etc/hosts", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_absolute, &project, cx), +// "Absolute path should require confirmation" +// ); +// }); + +// // Test 3: Relative path without .zed should not require confirmation +// let input_relative = json!({ +// "display_description": "Edit file", +// "path": "root/src/main.rs", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// !tool.needs_confirmation(&input_relative, &project, cx), +// "Relative path without .zed should not require confirmation" +// ); +// }); + +// // Test 4: Path with .zed in the middle should require confirmation +// let input_zed_middle = json!({ +// "display_description": "Edit settings", +// "path": "root/.zed/tasks.json", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_zed_middle, &project, cx), +// "Path with .zed in any component should require confirmation" +// ); +// }); + +// // Test 5: When always_allow_tool_actions is enabled, no confirmation needed +// cx.update(|cx| { +// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); +// settings.always_allow_tool_actions = true; +// agent_settings::AgentSettings::override_global(settings, cx); + +// assert!( +// !tool.needs_confirmation(&input_with_zed, &project, cx), +// "When always_allow_tool_actions is true, no confirmation should be needed" +// ); +// assert!( +// !tool.needs_confirmation(&input_absolute, &project, cx), +// "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" +// ); +// }); +// } + +// #[gpui::test] +// async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { +// // Set up a custom config directory for testing +// let temp_dir = tempfile::tempdir().unwrap(); +// init_test_with_config(cx, temp_dir.path()); + +// let tool = Arc::new(EditFileTool); + +// // Test ui_text shows context for various paths +// let test_cases = vec![ +// ( +// json!({ +// "display_description": "Update config", +// "path": ".zed/settings.json", +// "mode": "edit" +// }), +// "Update config (local settings)", +// ".zed path should show local settings context", +// ), +// ( +// json!({ +// "display_description": "Fix bug", +// "path": "src/.zed/local.json", +// "mode": "edit" +// }), +// "Fix bug (local settings)", +// "Nested .zed path should show local settings context", +// ), +// ( +// json!({ +// "display_description": "Update readme", +// "path": "README.md", +// "mode": "edit" +// }), +// "Update readme", +// "Normal path should not show additional context", +// ), +// ( +// json!({ +// "display_description": "Edit config", +// "path": "config.zed", +// "mode": "edit" +// }), +// "Edit config", +// ".zed as extension should not show context", +// ), +// ]; + +// for (input, expected_text, description) in test_cases { +// cx.update(|_cx| { +// let ui_text = tool.ui_text(&input); +// assert_eq!(ui_text, expected_text, "Failed for case: {}", description); +// }); +// } +// } + +// #[gpui::test] +// async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); + +// // Create a project in /project directory +// fs.insert_tree("/project", json!({})).await; +// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + +// // Test file outside project requires confirmation +// let input_outside = json!({ +// "display_description": "Edit file", +// "path": "/outside/file.txt", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_outside, &project, cx), +// "File outside project should require confirmation" +// ); +// }); + +// // Test file inside project doesn't require confirmation +// let input_inside = json!({ +// "display_description": "Edit file", +// "path": "project/file.txt", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// !tool.needs_confirmation(&input_inside, &project, cx), +// "File inside project should not require confirmation" +// ); +// }); +// } + +// #[gpui::test] +// async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { +// // Set up a custom data directory for testing +// let temp_dir = tempfile::tempdir().unwrap(); +// init_test_with_config(cx, temp_dir.path()); + +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/home/user/myproject", json!({})).await; +// let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; + +// // Get the actual local settings folder name +// let local_settings_folder = paths::local_settings_folder_relative_path(); + +// // Test various config path patterns +// let test_cases = vec![ +// ( +// format!("{}/settings.json", local_settings_folder.display()), +// true, +// "Top-level local settings file".to_string(), +// ), +// ( +// format!( +// "myproject/{}/settings.json", +// local_settings_folder.display() +// ), +// true, +// "Local settings in project path".to_string(), +// ), +// ( +// format!("src/{}/config.toml", local_settings_folder.display()), +// true, +// "Local settings in subdirectory".to_string(), +// ), +// ( +// ".zed.backup/file.txt".to_string(), +// true, +// ".zed.backup is outside project".to_string(), +// ), +// ( +// "my.zed/file.txt".to_string(), +// true, +// "my.zed is outside project".to_string(), +// ), +// ( +// "myproject/src/file.zed".to_string(), +// false, +// ".zed as file extension".to_string(), +// ), +// ( +// "myproject/normal/path/file.rs".to_string(), +// false, +// "Normal file without config paths".to_string(), +// ), +// ]; + +// for (path, should_confirm, description) in test_cases { +// let input = json!({ +// "display_description": "Edit file", +// "path": path, +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert_eq!( +// tool.needs_confirmation(&input, &project, cx), +// should_confirm, +// "Failed for case: {} - path: {}", +// description, +// path +// ); +// }); +// } +// } + +// #[gpui::test] +// async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { +// // Set up a custom data directory for testing +// let temp_dir = tempfile::tempdir().unwrap(); +// init_test_with_config(cx, temp_dir.path()); + +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); + +// // Create test files in the global config directory +// let global_config_dir = paths::config_dir(); +// fs::create_dir_all(&global_config_dir).unwrap(); +// let global_settings_path = global_config_dir.join("settings.json"); +// fs::write(&global_settings_path, "{}").unwrap(); + +// fs.insert_tree("/project", json!({})).await; +// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + +// // Test global config paths +// let test_cases = vec![ +// ( +// global_settings_path.to_str().unwrap().to_string(), +// true, +// "Global settings file should require confirmation", +// ), +// ( +// global_config_dir +// .join("keymap.json") +// .to_str() +// .unwrap() +// .to_string(), +// true, +// "Global keymap file should require confirmation", +// ), +// ( +// "project/normal_file.rs".to_string(), +// false, +// "Normal project file should not require confirmation", +// ), +// ]; + +// for (path, should_confirm, description) in test_cases { +// let input = json!({ +// "display_description": "Edit file", +// "path": path, +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert_eq!( +// tool.needs_confirmation(&input, &project, cx), +// should_confirm, +// "Failed for case: {}", +// description +// ); +// }); +// } +// } + +// #[gpui::test] +// async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); + +// // Create multiple worktree directories +// fs.insert_tree( +// "/workspace/frontend", +// json!({ +// "src": { +// "main.js": "console.log('frontend');" +// } +// }), +// ) +// .await; +// fs.insert_tree( +// "/workspace/backend", +// json!({ +// "src": { +// "main.rs": "fn main() {}" +// } +// }), +// ) +// .await; +// fs.insert_tree( +// "/workspace/shared", +// json!({ +// ".zed": { +// "settings.json": "{}" +// } +// }), +// ) +// .await; + +// // Create project with multiple worktrees +// let project = Project::test( +// fs.clone(), +// [ +// path!("/workspace/frontend").as_ref(), +// path!("/workspace/backend").as_ref(), +// path!("/workspace/shared").as_ref(), +// ], +// cx, +// ) +// .await; + +// // Test files in different worktrees +// let test_cases = vec![ +// ("frontend/src/main.js", false, "File in first worktree"), +// ("backend/src/main.rs", false, "File in second worktree"), +// ( +// "shared/.zed/settings.json", +// true, +// ".zed file in third worktree", +// ), +// ("/etc/hosts", true, "Absolute path outside all worktrees"), +// ( +// "../outside/file.txt", +// true, +// "Relative path outside worktrees", +// ), +// ]; + +// for (path, should_confirm, description) in test_cases { +// let input = json!({ +// "display_description": "Edit file", +// "path": path, +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert_eq!( +// tool.needs_confirmation(&input, &project, cx), +// should_confirm, +// "Failed for case: {} - path: {}", +// description, +// path +// ); +// }); +// } +// } + +// #[gpui::test] +// async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/project", +// json!({ +// ".zed": { +// "settings.json": "{}" +// }, +// "src": { +// ".zed": { +// "local.json": "{}" +// } +// } +// }), +// ) +// .await; +// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + +// // Test edge cases +// let test_cases = vec![ +// // Empty path - find_project_path returns Some for empty paths +// ("", false, "Empty path is treated as project root"), +// // Root directory +// ("/", true, "Root directory should be outside project"), +// // Parent directory references - find_project_path resolves these +// ( +// "project/../other", +// false, +// "Path with .. is resolved by find_project_path", +// ), +// ( +// "project/./src/file.rs", +// false, +// "Path with . should work normally", +// ), +// // Windows-style paths (if on Windows) +// #[cfg(target_os = "windows")] +// ("C:\\Windows\\System32\\hosts", true, "Windows system path"), +// #[cfg(target_os = "windows")] +// ("project\\src\\main.rs", false, "Windows-style project path"), +// ]; + +// for (path, should_confirm, description) in test_cases { +// let input = json!({ +// "display_description": "Edit file", +// "path": path, +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert_eq!( +// tool.needs_confirmation(&input, &project, cx), +// should_confirm, +// "Failed for case: {} - path: {}", +// description, +// path +// ); +// }); +// } +// } + +// #[gpui::test] +// async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); + +// // Test UI text for various scenarios +// let test_cases = vec![ +// ( +// json!({ +// "display_description": "Update config", +// "path": ".zed/settings.json", +// "mode": "edit" +// }), +// "Update config (local settings)", +// ".zed path should show local settings context", +// ), +// ( +// json!({ +// "display_description": "Fix bug", +// "path": "src/.zed/local.json", +// "mode": "edit" +// }), +// "Fix bug (local settings)", +// "Nested .zed path should show local settings context", +// ), +// ( +// json!({ +// "display_description": "Update readme", +// "path": "README.md", +// "mode": "edit" +// }), +// "Update readme", +// "Normal path should not show additional context", +// ), +// ( +// json!({ +// "display_description": "Edit config", +// "path": "config.zed", +// "mode": "edit" +// }), +// "Edit config", +// ".zed as extension should not show context", +// ), +// ]; + +// for (input, expected_text, description) in test_cases { +// cx.update(|_cx| { +// let ui_text = tool.ui_text(&input); +// assert_eq!(ui_text, expected_text, "Failed for case: {}", description); +// }); +// } +// } + +// #[gpui::test] +// async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { +// init_test(cx); +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/project", +// json!({ +// "existing.txt": "content", +// ".zed": { +// "settings.json": "{}" +// } +// }), +// ) +// .await; +// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + +// // Test different EditFileMode values +// let modes = vec![ +// EditFileMode::Edit, +// EditFileMode::Create, +// EditFileMode::Overwrite, +// ]; + +// for mode in modes { +// // Test .zed path with different modes +// let input_zed = json!({ +// "display_description": "Edit settings", +// "path": "project/.zed/settings.json", +// "mode": mode +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_zed, &project, cx), +// ".zed path should require confirmation regardless of mode: {:?}", +// mode +// ); +// }); + +// // Test outside path with different modes +// let input_outside = json!({ +// "display_description": "Edit file", +// "path": "/outside/file.txt", +// "mode": mode +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input_outside, &project, cx), +// "Outside path should require confirmation regardless of mode: {:?}", +// mode +// ); +// }); + +// // Test normal path with different modes +// let input_normal = json!({ +// "display_description": "Edit file", +// "path": "project/normal.txt", +// "mode": mode +// }); +// cx.update(|cx| { +// assert!( +// !tool.needs_confirmation(&input_normal, &project, cx), +// "Normal path should not require confirmation regardless of mode: {:?}", +// mode +// ); +// }); +// } +// } + +// #[gpui::test] +// async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { +// // Set up with custom directories for deterministic testing +// let temp_dir = tempfile::tempdir().unwrap(); +// init_test_with_config(cx, temp_dir.path()); + +// let tool = Arc::new(EditFileTool); +// let fs = project::FakeFs::new(cx.executor()); +// fs.insert_tree("/project", json!({})).await; +// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + +// // Enable always_allow_tool_actions +// cx.update(|cx| { +// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); +// settings.always_allow_tool_actions = true; +// agent_settings::AgentSettings::override_global(settings, cx); +// }); + +// // Test that all paths that normally require confirmation are bypassed +// let global_settings_path = paths::config_dir().join("settings.json"); +// fs::create_dir_all(paths::config_dir()).unwrap(); +// fs::write(&global_settings_path, "{}").unwrap(); + +// let test_cases = vec![ +// ".zed/settings.json", +// "project/.zed/config.toml", +// global_settings_path.to_str().unwrap(), +// "/etc/hosts", +// "/absolute/path/file.txt", +// "../outside/project.txt", +// ]; + +// for path in test_cases { +// let input = json!({ +// "display_description": "Edit file", +// "path": path, +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// !tool.needs_confirmation(&input, &project, cx), +// "Path {} should not require confirmation when always_allow_tool_actions is true", +// path +// ); +// }); +// } + +// // Disable always_allow_tool_actions and verify confirmation is required again +// cx.update(|cx| { +// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); +// settings.always_allow_tool_actions = false; +// agent_settings::AgentSettings::override_global(settings, cx); +// }); + +// // Verify .zed path requires confirmation again +// let input = json!({ +// "display_description": "Edit file", +// "path": ".zed/settings.json", +// "mode": "edit" +// }); +// cx.update(|cx| { +// assert!( +// tool.needs_confirmation(&input, &project, cx), +// ".zed path should require confirmation when always_allow_tool_actions is false" +// ); +// }); +// } +// } diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 90bb2e9b7c..bf668e6918 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -2,7 +2,7 @@ mod copy_path_tool; mod create_directory_tool; mod delete_path_tool; mod diagnostics_tool; -mod edit_agent; +pub mod edit_agent; mod edit_file_tool; mod fetch_tool; mod find_path_tool; @@ -14,7 +14,7 @@ mod open_tool; mod project_notifications_tool; mod read_file_tool; mod schema; -mod templates; +pub mod templates; mod terminal_tool; mod thinking_tool; mod ui; From 294109c6dae55155219d2cbc5302088862ff6920 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 10:50:53 +0200 Subject: [PATCH 14/22] Refactor tool auth --- crates/agent2/src/tests/test_tools.rs | 4 +- crates/agent2/src/thread.rs | 80 +++++++++++++-------------- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index 9905dea996..207abc0c2b 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -99,11 +99,11 @@ impl AgentTool for ToolRequiringPermission { fn run( self: Arc, - input: Self::Input, + _input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let auth_check = self.authorize(input, event_stream); + let auth_check = event_stream.authorize("Authorize?"); cx.foreground_executor().spawn(async move { auth_check.await?; Ok("Allowed".to_string()) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 0c7d82c1c0..973761cd53 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -498,7 +498,13 @@ impl Thread { })); }; - let tool_result = self.run_tool(tool, tool_use.clone(), event_stream.clone(), cx); + let tool_event_stream = + ToolCallEventStream::new(&tool_use, tool.kind(), event_stream.clone()); + tool_event_stream.send_update(acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }); + let tool_result = tool.run(tool_use.input, tool_event_stream, cx); Some(cx.foreground_executor().spawn(async move { match tool_result.await { Ok(tool_output) => LanguageModelToolResult { @@ -519,25 +525,6 @@ impl Thread { })) } - fn run_tool( - &self, - tool: Arc, - tool_use: LanguageModelToolUse, - event_stream: AgentResponseEventStream, - cx: &mut Context, - ) -> Task> { - cx.spawn(async move |_this, cx| { - let tool_event_stream = ToolCallEventStream::new(tool_use.id, event_stream); - tool_event_stream.send_update(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); - - cx.update(|cx| tool.run(tool_use.input, tool_event_stream, cx))? - .await - }) - } - fn handle_tool_use_json_parse_error_event( &mut self, tool_use_id: LanguageModelToolUseId, @@ -694,16 +681,6 @@ where schemars::schema_for!(Self::Input) } - /// Allows the tool to authorize a given tool call with the user if necessary - fn authorize( - &self, - input: Self::Input, - event_stream: ToolCallEventStream, - ) -> impl use + Future> { - let json_input = serde_json::json!(&input); - event_stream.authorize(self.initial_title(input).into(), self.kind(), json_input) - } - /// Runs the tool with the provided input. fn run( self: Arc, @@ -918,13 +895,21 @@ impl AgentResponseEventStream { #[derive(Clone)] pub struct ToolCallEventStream { tool_use_id: LanguageModelToolUseId, + kind: acp::ToolKind, + input: serde_json::Value, stream: AgentResponseEventStream, } impl ToolCallEventStream { - fn new(tool_use_id: LanguageModelToolUseId, stream: AgentResponseEventStream) -> Self { + fn new( + tool_use: &LanguageModelToolUse, + kind: acp::ToolKind, + stream: AgentResponseEventStream, + ) -> Self { Self { - tool_use_id, + tool_use_id: tool_use.id.clone(), + kind, + input: tool_use.input.clone(), stream, } } @@ -940,14 +925,17 @@ impl ToolCallEventStream { }); } - pub fn authorize( - &self, - title: String, - kind: acp::ToolKind, - input: serde_json::Value, - ) -> impl use<> + Future> { - self.stream - .authorize_tool_call(&self.tool_use_id, title, kind, input) + pub fn authorize(&self, title: T) -> impl use + Future> + where + T: Into, + { + let title = title.into(); + self.stream.authorize_tool_call( + &self.tool_use_id, + title, + self.kind.clone(), + self.input.clone(), + ) } } @@ -963,7 +951,17 @@ impl TestToolCallEventStream { let (events_tx, events_rx) = mpsc::unbounded::>(); - let stream = ToolCallEventStream::new("test".into(), AgentResponseEventStream(events_tx)); + let stream = ToolCallEventStream::new( + &LanguageModelToolUse { + id: "test_id".into(), + name: "test_tool".into(), + raw_input: String::new(), + input: serde_json::Value::Null, + is_input_complete: true, + }, + acp::ToolKind::Other, + AgentResponseEventStream(events_tx), + ); Self { stream, From da5f2978fdaea9d22c07c859c74bb084f0023e66 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 11:10:02 +0200 Subject: [PATCH 15/22] Implement auth for edit_file_tool Co-authored-by: Ben Brandt --- crates/agent2/src/tests/test_tools.rs | 2 +- crates/agent2/src/thread.rs | 6 +- crates/agent2/src/tools/edit_file_tool.rs | 104 +++++++++++----------- 3 files changed, 55 insertions(+), 57 deletions(-) diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index 207abc0c2b..f5030994e0 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -103,7 +103,7 @@ impl AgentTool for ToolRequiringPermission { event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let auth_check = event_stream.authorize("Authorize?"); + let auth_check = event_stream.authorize("Authorize?".into()); cx.foreground_executor().spawn(async move { auth_check.await?; Ok("Allowed".to_string()) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 973761cd53..0d6bb1608c 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -925,11 +925,7 @@ impl ToolCallEventStream { }); } - pub fn authorize(&self, title: T) -> impl use + Future> - where - T: Into, - { - let title = title.into(); + pub fn authorize(&self, title: String) -> impl use<> + Future> { self.stream.authorize_tool_call( &self.tool_use_id, title, diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index c3ea890f19..4fc547ff46 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -12,6 +12,7 @@ use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{Project, ProjectPath}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use smol::stream::StreamExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -98,6 +99,54 @@ impl EditFileTool { pub fn new(project: Entity, thread: Entity) -> Self { Self { project, thread } } + + fn authorize( + &self, + input: &EditFileToolInput, + event_stream: &ToolCallEventStream, + cx: &App, + ) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return Task::ready(Ok(())); + } + + // If any path component matches the local settings folder, then this could affect + // the editor in ways beyond the project source, so prompt. + let local_settings_folder = paths::local_settings_folder_relative_path(); + let path = Path::new(&input.path); + if path + .components() + .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) + { + return cx.foreground_executor().spawn( + event_stream.authorize(format!("{} (local settings)", input.display_description)), + ); + } + + // It's also possible that the global config dir is configured to be inside the project, + // so check for that edge case too. + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + return cx.foreground_executor().spawn( + event_stream + .authorize(format!("{} (global settings)", input.display_description)), + ); + } + } + + // Check if path is inside the global config directory + // First check if it's already inside project - if not, try to canonicalize + let project_path = self.project.read(cx).find_project_path(&input.path, cx); + + // If the path is inside the project, and it's not one of the above edge cases, + // then no confirmation is necessary. Otherwise, confirmation is necessary. + if project_path.is_some() { + Task::ready(Ok(())) + } else { + cx.foreground_executor() + .spawn(event_stream.authorize(input.display_description.clone())) + } + } } impl AgentTool for EditFileTool { @@ -112,59 +161,9 @@ impl AgentTool for EditFileTool { } fn initial_title(&self, input: Self::Input) -> SharedString { - let path = Path::new(&input.path); - let mut description = input.display_description.clone(); - - // Add context about why confirmation may be needed - let local_settings_folder = paths::local_settings_folder_relative_path(); - if path - .components() - .any(|c| c.as_os_str() == local_settings_folder.as_os_str()) - { - description.push_str(" (local settings)"); - } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { - if canonical_path.starts_with(paths::config_dir()) { - description.push_str(" (global settings)"); - } - } - - description.into() + input.display_description.into() } - // todo! - // fn needs_authorization(&self, input: Self::Input, cx: &App) -> bool { - // if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - // return false; - // } - - // // If any path component matches the local settings folder, then this could affect - // // the editor in ways beyond the project source, so prompt. - // let local_settings_folder = paths::local_settings_folder_relative_path(); - // let path = Path::new(&input.path); - // if path - // .components() - // .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) - // { - // return true; - // } - - // // It's also possible that the global config dir is configured to be inside the project, - // // so check for that edge case too. - // if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { - // if canonical_path.starts_with(paths::config_dir()) { - // return true; - // } - // } - - // // Check if path is inside the global config directory - // // First check if it's already inside project - if not, try to canonicalize - // let project_path = self.project.read(cx).find_project_path(&input.path, cx); - - // // If the path is inside the project, and it's not one of the above edge cases, - // // then no confirmation is necessary. Otherwise, confirmation is necessary. - // project_path.is_none() - // } - fn run( self: Arc, input: Self::Input, @@ -184,7 +183,10 @@ impl AgentTool for EditFileTool { let model = thread.selected_model.clone(); let action_log = thread.action_log().clone(); + let authorize = self.authorize(&input, &event_stream, cx); cx.spawn(async move |cx: &mut AsyncApp| { + authorize.await?; + let edit_format = EditFormat::from_model(model.clone())?; let edit_agent = EditAgent::new( model, From 8f390d9c6d23c4c9bb5bb771a76badca4bd4b71a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 11:27:08 +0200 Subject: [PATCH 16/22] Start porting tests for `EditFileTool` Co-authored-by: Ben Brandt --- crates/agent2/src/agent.rs | 2 +- crates/agent2/src/agent2.rs | 1 + crates/agent2/src/tests/mod.rs | 1 - crates/agent2/src/thread.rs | 76 +- crates/agent2/src/tools/edit_file_tool.rs | 2429 ++++++++++----------- crates/agent2/src/tools/read_file_tool.rs | 59 +- 6 files changed, 1271 insertions(+), 1297 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 5f84c0c96d..e7920e7891 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -417,7 +417,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { thread.add_tool(ThinkingTool); thread.add_tool(FindPathTool::new(project.clone())); thread.add_tool(ReadFileTool::new(project.clone(), action_log)); - thread.add_tool(EditFileTool::new(project.clone(), cx.entity())); + thread.add_tool(EditFileTool::new(cx.entity())); thread }); diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index db743c8429..f13cd1bd67 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -9,5 +9,6 @@ mod tests; pub use agent::*; pub use native_agent_server::NativeAgentServer; +pub use templates::*; pub use thread::*; pub use tools::*; diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 7913f9a24c..26cf38741b 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1,5 +1,4 @@ use super::*; -use crate::templates::Templates; use acp_thread::AgentConnection; use agent_client_protocol::{self as acp}; use anyhow::Result; diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 0d6bb1608c..31ace16124 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1,4 +1,4 @@ -use crate::templates::{SystemPromptTemplate, Template, Templates}; +use crate::{SystemPromptTemplate, Template, Templates}; use acp_thread::Diff; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; @@ -133,12 +133,13 @@ pub struct Thread { project_context: Rc>, templates: Arc, pub selected_model: Arc, + project: Entity, action_log: Entity, } impl Thread { pub fn new( - _project: Entity, + project: Entity, project_context: Rc>, action_log: Entity, templates: Arc, @@ -153,10 +154,19 @@ impl Thread { project_context, templates, selected_model: default_model, + project, action_log, } } + pub fn project(&self) -> &Entity { + &self.project + } + + pub fn action_log(&self) -> &Entity { + &self.action_log + } + pub fn set_mode(&mut self, mode: CompletionMode) { self.completion_mode = mode; } @@ -323,10 +333,6 @@ impl Thread { events_rx } - pub fn action_log(&self) -> &Entity { - &self.action_log - } - pub fn build_system_message(&self) -> AgentMessage { log::debug!("Building system message"); let prompt = SystemPromptTemplate { @@ -901,6 +907,29 @@ pub struct ToolCallEventStream { } impl ToolCallEventStream { + #[cfg(test)] + pub fn test() -> ( + Self, + mpsc::UnboundedReceiver>, + ) { + let (events_tx, events_rx) = + mpsc::unbounded::>(); + + let stream = ToolCallEventStream::new( + &LanguageModelToolUse { + id: "test_id".into(), + name: "test_tool".into(), + raw_input: String::new(), + input: serde_json::Value::Null, + is_input_complete: true, + }, + acp::ToolKind::Other, + AgentResponseEventStream(events_tx), + ); + + (stream, events_rx) + } + fn new( tool_use: &LanguageModelToolUse, kind: acp::ToolKind, @@ -934,38 +963,3 @@ impl ToolCallEventStream { ) } } - -#[cfg(test)] -pub struct TestToolCallEventStream { - stream: ToolCallEventStream, - _events_rx: mpsc::UnboundedReceiver>, -} - -#[cfg(test)] -impl TestToolCallEventStream { - pub fn new() -> Self { - let (events_tx, events_rx) = - mpsc::unbounded::>(); - - let stream = ToolCallEventStream::new( - &LanguageModelToolUse { - id: "test_id".into(), - name: "test_tool".into(), - raw_input: String::new(), - input: serde_json::Value::Null, - is_input_complete: true, - }, - acp::ToolKind::Other, - AgentResponseEventStream(events_tx), - ); - - Self { - stream, - _events_rx: events_rx, - } - } - - pub fn stream(&self) -> ToolCallEventStream { - self.stream.clone() - } -} diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 4fc547ff46..3cb325f459 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -75,11 +75,6 @@ pub struct EditFileToolInput { /// When a file already exists or you just created it, prefer editing /// it as opposed to recreating it from scratch. pub mode: EditFileMode, - - /// The new content for the file (required for create and overwrite modes) - /// For edit mode, this field is not used - edits happen through the edit agent - #[serde(default)] - pub content: Option, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -91,13 +86,12 @@ pub enum EditFileMode { } pub struct EditFileTool { - project: Entity, thread: Entity, } impl EditFileTool { - pub fn new(project: Entity, thread: Entity) -> Self { - Self { project, thread } + pub fn new(thread: Entity) -> Self { + Self { thread } } fn authorize( @@ -136,7 +130,8 @@ impl EditFileTool { // Check if path is inside the global config directory // First check if it's already inside project - if not, try to canonicalize - let project_path = self.project.read(cx).find_project_path(&input.path, cx); + let thread = self.thread.read(cx); + let project_path = thread.project().read(cx).find_project_path(&input.path, cx); // If the path is inside the project, and it's not one of the above edge cases, // then no confirmation is necessary. Otherwise, confirmation is necessary. @@ -170,12 +165,12 @@ impl AgentTool for EditFileTool { event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let project_path = match resolve_path(&input, self.project.clone(), cx) { + let project = self.thread.read(cx).project().clone(); + let project_path = match resolve_path(&input, project.clone(), cx) { Ok(path) => path, Err(err) => return Task::ready(Err(anyhow!(err))).into(), }; - let project = self.project.clone(); let request = self.thread.update(cx, |thread, cx| { thread.build_completion_request(CompletionIntent::ToolResults, cx) }); @@ -410,1213 +405,1205 @@ fn resolve_path( } } -// todo! restore tests -// #[cfg(test)] -// mod tests { -// use super::*; -// use ::fs::Fs; -// use client::TelemetrySettings; -// use gpui::{TestAppContext, UpdateGlobal}; -// use language_model::fake_provider::FakeLanguageModel; -// use serde_json::json; -// use settings::SettingsStore; -// use std::fs; -// use util::path; - -// #[gpui::test] -// async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { -// init_test(cx); - -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/root", json!({})).await; -// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; -// let action_log = cx.new(|_| ActionLog::new(project.clone())); -// let model = Arc::new(FakeLanguageModel::default()); -// let result = cx -// .update(|cx| { -// let input = serde_json::to_value(EditFileToolInput { -// display_description: "Some edit".into(), -// path: "root/nonexistent_file.txt".into(), -// mode: EditFileMode::Edit, -// }) -// .unwrap(); -// Arc::new(EditFileTool) -// .run( -// input, -// Arc::default(), -// project.clone(), -// action_log, -// model, -// None, -// cx, -// ) -// .output -// }) -// .await; -// assert_eq!( -// result.unwrap_err().to_string(), -// "Can't edit file: path not found" -// ); -// } - -// #[gpui::test] -// async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { -// let mode = &EditFileMode::Create; - -// let result = test_resolve_path(mode, "root/new.txt", cx); -// assert_resolved_path_eq(result.await, "new.txt"); - -// let result = test_resolve_path(mode, "new.txt", cx); -// assert_resolved_path_eq(result.await, "new.txt"); - -// let result = test_resolve_path(mode, "dir/new.txt", cx); -// assert_resolved_path_eq(result.await, "dir/new.txt"); - -// let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); -// assert_eq!( -// result.await.unwrap_err().to_string(), -// "Can't create file: file already exists" -// ); - -// let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); -// assert_eq!( -// result.await.unwrap_err().to_string(), -// "Can't create file: parent directory doesn't exist" -// ); -// } - -// #[gpui::test] -// async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { -// let mode = &EditFileMode::Edit; - -// let path_with_root = "root/dir/subdir/existing.txt"; -// let path_without_root = "dir/subdir/existing.txt"; -// let result = test_resolve_path(mode, path_with_root, cx); -// assert_resolved_path_eq(result.await, path_without_root); - -// let result = test_resolve_path(mode, path_without_root, cx); -// assert_resolved_path_eq(result.await, path_without_root); - -// let result = test_resolve_path(mode, "root/nonexistent.txt", cx); -// assert_eq!( -// result.await.unwrap_err().to_string(), -// "Can't edit file: path not found" -// ); - -// let result = test_resolve_path(mode, "root/dir", cx); -// assert_eq!( -// result.await.unwrap_err().to_string(), -// "Can't edit file: path is a directory" -// ); -// } - -// async fn test_resolve_path( -// mode: &EditFileMode, -// path: &str, -// cx: &mut TestAppContext, -// ) -> anyhow::Result { -// init_test(cx); - -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree( -// "/root", -// json!({ -// "dir": { -// "subdir": { -// "existing.txt": "hello" -// } -// } -// }), -// ) -// .await; -// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - -// let input = EditFileToolInput { -// display_description: "Some edit".into(), -// path: path.into(), -// mode: mode.clone(), -// }; - -// let result = cx.update(|cx| resolve_path(&input, project, cx)); -// result -// } - -// fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { -// let actual = path -// .expect("Should return valid path") -// .path -// .to_str() -// .unwrap() -// .replace("\\", "/"); // Naive Windows paths normalization -// assert_eq!(actual, expected); -// } - -// #[test] -// fn still_streaming_ui_text_with_path() { -// let input = json!({ -// "path": "src/main.rs", -// "display_description": "", -// "old_string": "old code", -// "new_string": "new code" -// }); - -// assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); -// } - -// #[test] -// fn still_streaming_ui_text_with_description() { -// let input = json!({ -// "path": "", -// "display_description": "Fix error handling", -// "old_string": "old code", -// "new_string": "new code" -// }); - -// assert_eq!( -// EditFileTool.still_streaming_ui_text(&input), -// "Fix error handling", -// ); -// } - -// #[test] -// fn still_streaming_ui_text_with_path_and_description() { -// let input = json!({ -// "path": "src/main.rs", -// "display_description": "Fix error handling", -// "old_string": "old code", -// "new_string": "new code" -// }); - -// assert_eq!( -// EditFileTool.still_streaming_ui_text(&input), -// "Fix error handling", -// ); -// } - -// #[test] -// fn still_streaming_ui_text_no_path_or_description() { -// let input = json!({ -// "path": "", -// "display_description": "", -// "old_string": "old code", -// "new_string": "new code" -// }); - -// assert_eq!( -// EditFileTool.still_streaming_ui_text(&input), -// DEFAULT_UI_TEXT, -// ); -// } - -// #[test] -// fn still_streaming_ui_text_with_null() { -// let input = serde_json::Value::Null; - -// assert_eq!( -// EditFileTool.still_streaming_ui_text(&input), -// DEFAULT_UI_TEXT, -// ); -// } - -// fn init_test(cx: &mut TestAppContext) { -// cx.update(|cx| { -// let settings_store = SettingsStore::test(cx); -// cx.set_global(settings_store); -// language::init(cx); -// TelemetrySettings::register(cx); -// agent_settings::AgentSettings::register(cx); -// Project::init_settings(cx); -// }); -// } - -// fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { -// cx.update(|cx| { -// // Set custom data directory (config will be under data_dir/config) -// paths::set_custom_data_dir(data_dir.to_str().unwrap()); - -// let settings_store = SettingsStore::test(cx); -// cx.set_global(settings_store); -// language::init(cx); -// TelemetrySettings::register(cx); -// agent_settings::AgentSettings::register(cx); -// Project::init_settings(cx); -// }); -// } - -// #[gpui::test] -// async fn test_format_on_save(cx: &mut TestAppContext) { -// init_test(cx); - -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/root", json!({"src": {}})).await; - -// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - -// // Set up a Rust language with LSP formatting support -// let rust_language = Arc::new(language::Language::new( -// language::LanguageConfig { -// name: "Rust".into(), -// matcher: language::LanguageMatcher { -// path_suffixes: vec!["rs".to_string()], -// ..Default::default() -// }, -// ..Default::default() -// }, -// None, -// )); - -// // Register the language and fake LSP -// let language_registry = project.read_with(cx, |project, _| project.languages().clone()); -// language_registry.add(rust_language); - -// let mut fake_language_servers = language_registry.register_fake_lsp( -// "Rust", -// language::FakeLspAdapter { -// capabilities: lsp::ServerCapabilities { -// document_formatting_provider: Some(lsp::OneOf::Left(true)), -// ..Default::default() -// }, -// ..Default::default() -// }, -// ); - -// // Create the file -// fs.save( -// path!("/root/src/main.rs").as_ref(), -// &"initial content".into(), -// language::LineEnding::Unix, -// ) -// .await -// .unwrap(); - -// // Open the buffer to trigger LSP initialization -// let buffer = project -// .update(cx, |project, cx| { -// project.open_local_buffer(path!("/root/src/main.rs"), cx) -// }) -// .await -// .unwrap(); - -// // Register the buffer with language servers -// let _handle = project.update(cx, |project, cx| { -// project.register_buffer_with_language_servers(&buffer, cx) -// }); - -// const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; -// const FORMATTED_CONTENT: &str = -// "This file was formatted by the fake formatter in the test.\n"; - -// // Get the fake language server and set up formatting handler -// let fake_language_server = fake_language_servers.next().await.unwrap(); -// fake_language_server.set_request_handler::({ -// |_, _| async move { -// Ok(Some(vec![lsp::TextEdit { -// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), -// new_text: FORMATTED_CONTENT.to_string(), -// }])) -// } -// }); - -// let action_log = cx.new(|_| ActionLog::new(project.clone())); -// let model = Arc::new(FakeLanguageModel::default()); - -// // First, test with format_on_save enabled -// cx.update(|cx| { -// SettingsStore::update_global(cx, |store, cx| { -// store.update_user_settings::( -// cx, -// |settings| { -// settings.defaults.format_on_save = Some(FormatOnSave::On); -// settings.defaults.formatter = -// Some(language::language_settings::SelectedFormatter::Auto); -// }, -// ); -// }); -// }); - -// // Have the model stream unformatted content -// let edit_result = { -// let edit_task = cx.update(|cx| { -// let input = serde_json::to_value(EditFileToolInput { -// display_description: "Create main function".into(), -// path: "root/src/main.rs".into(), -// mode: EditFileMode::Overwrite, -// }) -// .unwrap(); -// Arc::new(EditFileTool) -// .run( -// input, -// Arc::default(), -// project.clone(), -// action_log.clone(), -// model.clone(), -// None, -// cx, -// ) -// .output -// }); - -// // Stream the unformatted content -// cx.executor().run_until_parked(); -// model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); -// model.end_last_completion_stream(); - -// edit_task.await -// }; -// assert!(edit_result.is_ok()); - -// // Wait for any async operations (e.g. formatting) to complete -// cx.executor().run_until_parked(); - -// // Read the file to verify it was formatted automatically -// let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); -// assert_eq!( -// // Ignore carriage returns on Windows -// new_content.replace("\r\n", "\n"), -// FORMATTED_CONTENT, -// "Code should be formatted when format_on_save is enabled" -// ); - -// let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); - -// assert_eq!( -// stale_buffer_count, 0, -// "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ -// This causes the agent to think the file was modified externally when it was just formatted.", -// stale_buffer_count -// ); - -// // Next, test with format_on_save disabled -// cx.update(|cx| { -// SettingsStore::update_global(cx, |store, cx| { -// store.update_user_settings::( -// cx, -// |settings| { -// settings.defaults.format_on_save = Some(FormatOnSave::Off); -// }, -// ); -// }); -// }); - -// // Stream unformatted edits again -// let edit_result = { -// let edit_task = cx.update(|cx| { -// let input = serde_json::to_value(EditFileToolInput { -// display_description: "Update main function".into(), -// path: "root/src/main.rs".into(), -// mode: EditFileMode::Overwrite, -// }) -// .unwrap(); -// Arc::new(EditFileTool) -// .run( -// input, -// Arc::default(), -// project.clone(), -// action_log.clone(), -// model.clone(), -// None, -// cx, -// ) -// .output -// }); - -// // Stream the unformatted content -// cx.executor().run_until_parked(); -// model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); -// model.end_last_completion_stream(); - -// edit_task.await -// }; -// assert!(edit_result.is_ok()); - -// // Wait for any async operations (e.g. formatting) to complete -// cx.executor().run_until_parked(); - -// // Verify the file was not formatted -// let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); -// assert_eq!( -// // Ignore carriage returns on Windows -// new_content.replace("\r\n", "\n"), -// UNFORMATTED_CONTENT, -// "Code should not be formatted when format_on_save is disabled" -// ); -// } - -// #[gpui::test] -// async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { -// init_test(cx); - -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/root", json!({"src": {}})).await; - -// // Create a simple file with trailing whitespace -// fs.save( -// path!("/root/src/main.rs").as_ref(), -// &"initial content".into(), -// language::LineEnding::Unix, -// ) -// .await -// .unwrap(); - -// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; -// let action_log = cx.new(|_| ActionLog::new(project.clone())); -// let model = Arc::new(FakeLanguageModel::default()); - -// // First, test with remove_trailing_whitespace_on_save enabled -// cx.update(|cx| { -// SettingsStore::update_global(cx, |store, cx| { -// store.update_user_settings::( -// cx, -// |settings| { -// settings.defaults.remove_trailing_whitespace_on_save = Some(true); -// }, -// ); -// }); -// }); - -// const CONTENT_WITH_TRAILING_WHITESPACE: &str = -// "fn main() { \n println!(\"Hello!\"); \n}\n"; - -// // Have the model stream content that contains trailing whitespace -// let edit_result = { -// let edit_task = cx.update(|cx| { -// let input = serde_json::to_value(EditFileToolInput { -// display_description: "Create main function".into(), -// path: "root/src/main.rs".into(), -// mode: EditFileMode::Overwrite, -// }) -// .unwrap(); -// Arc::new(EditFileTool) -// .run( -// input, -// Arc::default(), -// project.clone(), -// action_log.clone(), -// model.clone(), -// None, -// cx, -// ) -// .output -// }); - -// // Stream the content with trailing whitespace -// cx.executor().run_until_parked(); -// model.send_last_completion_stream_text_chunk( -// CONTENT_WITH_TRAILING_WHITESPACE.to_string(), -// ); -// model.end_last_completion_stream(); - -// edit_task.await -// }; -// assert!(edit_result.is_ok()); - -// // Wait for any async operations (e.g. formatting) to complete -// cx.executor().run_until_parked(); - -// // Read the file to verify trailing whitespace was removed automatically -// assert_eq!( -// // Ignore carriage returns on Windows -// fs.load(path!("/root/src/main.rs").as_ref()) -// .await -// .unwrap() -// .replace("\r\n", "\n"), -// "fn main() {\n println!(\"Hello!\");\n}\n", -// "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" -// ); - -// // Next, test with remove_trailing_whitespace_on_save disabled -// cx.update(|cx| { -// SettingsStore::update_global(cx, |store, cx| { -// store.update_user_settings::( -// cx, -// |settings| { -// settings.defaults.remove_trailing_whitespace_on_save = Some(false); -// }, -// ); -// }); -// }); - -// // Stream edits again with trailing whitespace -// let edit_result = { -// let edit_task = cx.update(|cx| { -// let input = serde_json::to_value(EditFileToolInput { -// display_description: "Update main function".into(), -// path: "root/src/main.rs".into(), -// mode: EditFileMode::Overwrite, -// }) -// .unwrap(); -// Arc::new(EditFileTool) -// .run( -// input, -// Arc::default(), -// project.clone(), -// action_log.clone(), -// model.clone(), -// None, -// cx, -// ) -// .output -// }); - -// // Stream the content with trailing whitespace -// cx.executor().run_until_parked(); -// model.send_last_completion_stream_text_chunk( -// CONTENT_WITH_TRAILING_WHITESPACE.to_string(), -// ); -// model.end_last_completion_stream(); - -// edit_task.await -// }; -// assert!(edit_result.is_ok()); - -// // Wait for any async operations (e.g. formatting) to complete -// cx.executor().run_until_parked(); - -// // Verify the file still has trailing whitespace -// // Read the file again - it should still have trailing whitespace -// let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); -// assert_eq!( -// // Ignore carriage returns on Windows -// final_content.replace("\r\n", "\n"), -// CONTENT_WITH_TRAILING_WHITESPACE, -// "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" -// ); -// } - -// #[gpui::test] -// async fn test_needs_confirmation(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/root", json!({})).await; - -// // Test 1: Path with .zed component should require confirmation -// let input_with_zed = json!({ -// "display_description": "Edit settings", -// "path": ".zed/settings.json", -// "mode": "edit" -// }); -// let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_with_zed, &project, cx), -// "Path with .zed component should require confirmation" -// ); -// }); - -// // Test 2: Absolute path should require confirmation -// let input_absolute = json!({ -// "display_description": "Edit file", -// "path": "/etc/hosts", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_absolute, &project, cx), -// "Absolute path should require confirmation" -// ); -// }); - -// // Test 3: Relative path without .zed should not require confirmation -// let input_relative = json!({ -// "display_description": "Edit file", -// "path": "root/src/main.rs", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// !tool.needs_confirmation(&input_relative, &project, cx), -// "Relative path without .zed should not require confirmation" -// ); -// }); - -// // Test 4: Path with .zed in the middle should require confirmation -// let input_zed_middle = json!({ -// "display_description": "Edit settings", -// "path": "root/.zed/tasks.json", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_zed_middle, &project, cx), -// "Path with .zed in any component should require confirmation" -// ); -// }); - -// // Test 5: When always_allow_tool_actions is enabled, no confirmation needed -// cx.update(|cx| { -// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); -// settings.always_allow_tool_actions = true; -// agent_settings::AgentSettings::override_global(settings, cx); - -// assert!( -// !tool.needs_confirmation(&input_with_zed, &project, cx), -// "When always_allow_tool_actions is true, no confirmation should be needed" -// ); -// assert!( -// !tool.needs_confirmation(&input_absolute, &project, cx), -// "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" -// ); -// }); -// } - -// #[gpui::test] -// async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { -// // Set up a custom config directory for testing -// let temp_dir = tempfile::tempdir().unwrap(); -// init_test_with_config(cx, temp_dir.path()); - -// let tool = Arc::new(EditFileTool); - -// // Test ui_text shows context for various paths -// let test_cases = vec![ -// ( -// json!({ -// "display_description": "Update config", -// "path": ".zed/settings.json", -// "mode": "edit" -// }), -// "Update config (local settings)", -// ".zed path should show local settings context", -// ), -// ( -// json!({ -// "display_description": "Fix bug", -// "path": "src/.zed/local.json", -// "mode": "edit" -// }), -// "Fix bug (local settings)", -// "Nested .zed path should show local settings context", -// ), -// ( -// json!({ -// "display_description": "Update readme", -// "path": "README.md", -// "mode": "edit" -// }), -// "Update readme", -// "Normal path should not show additional context", -// ), -// ( -// json!({ -// "display_description": "Edit config", -// "path": "config.zed", -// "mode": "edit" -// }), -// "Edit config", -// ".zed as extension should not show context", -// ), -// ]; - -// for (input, expected_text, description) in test_cases { -// cx.update(|_cx| { -// let ui_text = tool.ui_text(&input); -// assert_eq!(ui_text, expected_text, "Failed for case: {}", description); -// }); -// } -// } - -// #[gpui::test] -// async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); - -// // Create a project in /project directory -// fs.insert_tree("/project", json!({})).await; -// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - -// // Test file outside project requires confirmation -// let input_outside = json!({ -// "display_description": "Edit file", -// "path": "/outside/file.txt", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_outside, &project, cx), -// "File outside project should require confirmation" -// ); -// }); - -// // Test file inside project doesn't require confirmation -// let input_inside = json!({ -// "display_description": "Edit file", -// "path": "project/file.txt", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// !tool.needs_confirmation(&input_inside, &project, cx), -// "File inside project should not require confirmation" -// ); -// }); -// } - -// #[gpui::test] -// async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { -// // Set up a custom data directory for testing -// let temp_dir = tempfile::tempdir().unwrap(); -// init_test_with_config(cx, temp_dir.path()); - -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/home/user/myproject", json!({})).await; -// let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - -// // Get the actual local settings folder name -// let local_settings_folder = paths::local_settings_folder_relative_path(); - -// // Test various config path patterns -// let test_cases = vec![ -// ( -// format!("{}/settings.json", local_settings_folder.display()), -// true, -// "Top-level local settings file".to_string(), -// ), -// ( -// format!( -// "myproject/{}/settings.json", -// local_settings_folder.display() -// ), -// true, -// "Local settings in project path".to_string(), -// ), -// ( -// format!("src/{}/config.toml", local_settings_folder.display()), -// true, -// "Local settings in subdirectory".to_string(), -// ), -// ( -// ".zed.backup/file.txt".to_string(), -// true, -// ".zed.backup is outside project".to_string(), -// ), -// ( -// "my.zed/file.txt".to_string(), -// true, -// "my.zed is outside project".to_string(), -// ), -// ( -// "myproject/src/file.zed".to_string(), -// false, -// ".zed as file extension".to_string(), -// ), -// ( -// "myproject/normal/path/file.rs".to_string(), -// false, -// "Normal file without config paths".to_string(), -// ), -// ]; - -// for (path, should_confirm, description) in test_cases { -// let input = json!({ -// "display_description": "Edit file", -// "path": path, -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert_eq!( -// tool.needs_confirmation(&input, &project, cx), -// should_confirm, -// "Failed for case: {} - path: {}", -// description, -// path -// ); -// }); -// } -// } - -// #[gpui::test] -// async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { -// // Set up a custom data directory for testing -// let temp_dir = tempfile::tempdir().unwrap(); -// init_test_with_config(cx, temp_dir.path()); - -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); - -// // Create test files in the global config directory -// let global_config_dir = paths::config_dir(); -// fs::create_dir_all(&global_config_dir).unwrap(); -// let global_settings_path = global_config_dir.join("settings.json"); -// fs::write(&global_settings_path, "{}").unwrap(); - -// fs.insert_tree("/project", json!({})).await; -// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - -// // Test global config paths -// let test_cases = vec![ -// ( -// global_settings_path.to_str().unwrap().to_string(), -// true, -// "Global settings file should require confirmation", -// ), -// ( -// global_config_dir -// .join("keymap.json") -// .to_str() -// .unwrap() -// .to_string(), -// true, -// "Global keymap file should require confirmation", -// ), -// ( -// "project/normal_file.rs".to_string(), -// false, -// "Normal project file should not require confirmation", -// ), -// ]; - -// for (path, should_confirm, description) in test_cases { -// let input = json!({ -// "display_description": "Edit file", -// "path": path, -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert_eq!( -// tool.needs_confirmation(&input, &project, cx), -// should_confirm, -// "Failed for case: {}", -// description -// ); -// }); -// } -// } - -// #[gpui::test] -// async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); - -// // Create multiple worktree directories -// fs.insert_tree( -// "/workspace/frontend", -// json!({ -// "src": { -// "main.js": "console.log('frontend');" -// } -// }), -// ) -// .await; -// fs.insert_tree( -// "/workspace/backend", -// json!({ -// "src": { -// "main.rs": "fn main() {}" -// } -// }), -// ) -// .await; -// fs.insert_tree( -// "/workspace/shared", -// json!({ -// ".zed": { -// "settings.json": "{}" -// } -// }), -// ) -// .await; - -// // Create project with multiple worktrees -// let project = Project::test( -// fs.clone(), -// [ -// path!("/workspace/frontend").as_ref(), -// path!("/workspace/backend").as_ref(), -// path!("/workspace/shared").as_ref(), -// ], -// cx, -// ) -// .await; - -// // Test files in different worktrees -// let test_cases = vec![ -// ("frontend/src/main.js", false, "File in first worktree"), -// ("backend/src/main.rs", false, "File in second worktree"), -// ( -// "shared/.zed/settings.json", -// true, -// ".zed file in third worktree", -// ), -// ("/etc/hosts", true, "Absolute path outside all worktrees"), -// ( -// "../outside/file.txt", -// true, -// "Relative path outside worktrees", -// ), -// ]; - -// for (path, should_confirm, description) in test_cases { -// let input = json!({ -// "display_description": "Edit file", -// "path": path, -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert_eq!( -// tool.needs_confirmation(&input, &project, cx), -// should_confirm, -// "Failed for case: {} - path: {}", -// description, -// path -// ); -// }); -// } -// } - -// #[gpui::test] -// async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree( -// "/project", -// json!({ -// ".zed": { -// "settings.json": "{}" -// }, -// "src": { -// ".zed": { -// "local.json": "{}" -// } -// } -// }), -// ) -// .await; -// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - -// // Test edge cases -// let test_cases = vec![ -// // Empty path - find_project_path returns Some for empty paths -// ("", false, "Empty path is treated as project root"), -// // Root directory -// ("/", true, "Root directory should be outside project"), -// // Parent directory references - find_project_path resolves these -// ( -// "project/../other", -// false, -// "Path with .. is resolved by find_project_path", -// ), -// ( -// "project/./src/file.rs", -// false, -// "Path with . should work normally", -// ), -// // Windows-style paths (if on Windows) -// #[cfg(target_os = "windows")] -// ("C:\\Windows\\System32\\hosts", true, "Windows system path"), -// #[cfg(target_os = "windows")] -// ("project\\src\\main.rs", false, "Windows-style project path"), -// ]; - -// for (path, should_confirm, description) in test_cases { -// let input = json!({ -// "display_description": "Edit file", -// "path": path, -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert_eq!( -// tool.needs_confirmation(&input, &project, cx), -// should_confirm, -// "Failed for case: {} - path: {}", -// description, -// path -// ); -// }); -// } -// } - -// #[gpui::test] -// async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); - -// // Test UI text for various scenarios -// let test_cases = vec![ -// ( -// json!({ -// "display_description": "Update config", -// "path": ".zed/settings.json", -// "mode": "edit" -// }), -// "Update config (local settings)", -// ".zed path should show local settings context", -// ), -// ( -// json!({ -// "display_description": "Fix bug", -// "path": "src/.zed/local.json", -// "mode": "edit" -// }), -// "Fix bug (local settings)", -// "Nested .zed path should show local settings context", -// ), -// ( -// json!({ -// "display_description": "Update readme", -// "path": "README.md", -// "mode": "edit" -// }), -// "Update readme", -// "Normal path should not show additional context", -// ), -// ( -// json!({ -// "display_description": "Edit config", -// "path": "config.zed", -// "mode": "edit" -// }), -// "Edit config", -// ".zed as extension should not show context", -// ), -// ]; - -// for (input, expected_text, description) in test_cases { -// cx.update(|_cx| { -// let ui_text = tool.ui_text(&input); -// assert_eq!(ui_text, expected_text, "Failed for case: {}", description); -// }); -// } -// } - -// #[gpui::test] -// async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { -// init_test(cx); -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree( -// "/project", -// json!({ -// "existing.txt": "content", -// ".zed": { -// "settings.json": "{}" -// } -// }), -// ) -// .await; -// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - -// // Test different EditFileMode values -// let modes = vec![ -// EditFileMode::Edit, -// EditFileMode::Create, -// EditFileMode::Overwrite, -// ]; - -// for mode in modes { -// // Test .zed path with different modes -// let input_zed = json!({ -// "display_description": "Edit settings", -// "path": "project/.zed/settings.json", -// "mode": mode -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_zed, &project, cx), -// ".zed path should require confirmation regardless of mode: {:?}", -// mode -// ); -// }); - -// // Test outside path with different modes -// let input_outside = json!({ -// "display_description": "Edit file", -// "path": "/outside/file.txt", -// "mode": mode -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input_outside, &project, cx), -// "Outside path should require confirmation regardless of mode: {:?}", -// mode -// ); -// }); - -// // Test normal path with different modes -// let input_normal = json!({ -// "display_description": "Edit file", -// "path": "project/normal.txt", -// "mode": mode -// }); -// cx.update(|cx| { -// assert!( -// !tool.needs_confirmation(&input_normal, &project, cx), -// "Normal path should not require confirmation regardless of mode: {:?}", -// mode -// ); -// }); -// } -// } - -// #[gpui::test] -// async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { -// // Set up with custom directories for deterministic testing -// let temp_dir = tempfile::tempdir().unwrap(); -// init_test_with_config(cx, temp_dir.path()); - -// let tool = Arc::new(EditFileTool); -// let fs = project::FakeFs::new(cx.executor()); -// fs.insert_tree("/project", json!({})).await; -// let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - -// // Enable always_allow_tool_actions -// cx.update(|cx| { -// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); -// settings.always_allow_tool_actions = true; -// agent_settings::AgentSettings::override_global(settings, cx); -// }); - -// // Test that all paths that normally require confirmation are bypassed -// let global_settings_path = paths::config_dir().join("settings.json"); -// fs::create_dir_all(paths::config_dir()).unwrap(); -// fs::write(&global_settings_path, "{}").unwrap(); - -// let test_cases = vec![ -// ".zed/settings.json", -// "project/.zed/config.toml", -// global_settings_path.to_str().unwrap(), -// "/etc/hosts", -// "/absolute/path/file.txt", -// "../outside/project.txt", -// ]; - -// for path in test_cases { -// let input = json!({ -// "display_description": "Edit file", -// "path": path, -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// !tool.needs_confirmation(&input, &project, cx), -// "Path {} should not require confirmation when always_allow_tool_actions is true", -// path -// ); -// }); -// } - -// // Disable always_allow_tool_actions and verify confirmation is required again -// cx.update(|cx| { -// let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); -// settings.always_allow_tool_actions = false; -// agent_settings::AgentSettings::override_global(settings, cx); -// }); - -// // Verify .zed path requires confirmation again -// let input = json!({ -// "display_description": "Edit file", -// "path": ".zed/settings.json", -// "mode": "edit" -// }); -// cx.update(|cx| { -// assert!( -// tool.needs_confirmation(&input, &project, cx), -// ".zed path should require confirmation when always_allow_tool_actions is false" -// ); -// }); -// } -// } +#[cfg(test)] +mod tests { + use crate::Templates; + + use super::*; + use assistant_tool::ActionLog; + use client::TelemetrySettings; + use gpui::TestAppContext; + use language_model::fake_provider::FakeLanguageModel; + use serde_json::json; + use settings::SettingsStore; + use std::rc::Rc; + use util::path; + + #[gpui::test] + async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = + cx.new(|_| Thread::new(project, Rc::default(), action_log, Templates::new(), model)); + let result = cx + .update(|cx| { + let input = EditFileToolInput { + display_description: "Some edit".into(), + path: "root/nonexistent_file.txt".into(), + mode: EditFileMode::Edit, + }; + Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + }) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "Can't edit file: path not found" + ); + } + + // #[gpui::test] + // async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { + // let mode = &EditFileMode::Create; + + // let result = test_resolve_path(mode, "root/new.txt", cx); + // assert_resolved_path_eq(result.await, "new.txt"); + + // let result = test_resolve_path(mode, "new.txt", cx); + // assert_resolved_path_eq(result.await, "new.txt"); + + // let result = test_resolve_path(mode, "dir/new.txt", cx); + // assert_resolved_path_eq(result.await, "dir/new.txt"); + + // let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); + // assert_eq!( + // result.await.unwrap_err().to_string(), + // "Can't create file: file already exists" + // ); + + // let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); + // assert_eq!( + // result.await.unwrap_err().to_string(), + // "Can't create file: parent directory doesn't exist" + // ); + // } + + // #[gpui::test] + // async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { + // let mode = &EditFileMode::Edit; + + // let path_with_root = "root/dir/subdir/existing.txt"; + // let path_without_root = "dir/subdir/existing.txt"; + // let result = test_resolve_path(mode, path_with_root, cx); + // assert_resolved_path_eq(result.await, path_without_root); + + // let result = test_resolve_path(mode, path_without_root, cx); + // assert_resolved_path_eq(result.await, path_without_root); + + // let result = test_resolve_path(mode, "root/nonexistent.txt", cx); + // assert_eq!( + // result.await.unwrap_err().to_string(), + // "Can't edit file: path not found" + // ); + + // let result = test_resolve_path(mode, "root/dir", cx); + // assert_eq!( + // result.await.unwrap_err().to_string(), + // "Can't edit file: path is a directory" + // ); + // } + + // async fn test_resolve_path( + // mode: &EditFileMode, + // path: &str, + // cx: &mut TestAppContext, + // ) -> anyhow::Result { + // init_test(cx); + + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree( + // "/root", + // json!({ + // "dir": { + // "subdir": { + // "existing.txt": "hello" + // } + // } + // }), + // ) + // .await; + // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // let input = EditFileToolInput { + // display_description: "Some edit".into(), + // path: path.into(), + // mode: mode.clone(), + // }; + + // let result = cx.update(|cx| resolve_path(&input, project, cx)); + // result + // } + + // fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { + // let actual = path + // .expect("Should return valid path") + // .path + // .to_str() + // .unwrap() + // .replace("\\", "/"); // Naive Windows paths normalization + // assert_eq!(actual, expected); + // } + + // #[test] + // fn still_streaming_ui_text_with_path() { + // let input = json!({ + // "path": "src/main.rs", + // "display_description": "", + // "old_string": "old code", + // "new_string": "new code" + // }); + + // assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); + // } + + // #[test] + // fn still_streaming_ui_text_with_description() { + // let input = json!({ + // "path": "", + // "display_description": "Fix error handling", + // "old_string": "old code", + // "new_string": "new code" + // }); + + // assert_eq!( + // EditFileTool.still_streaming_ui_text(&input), + // "Fix error handling", + // ); + // } + + // #[test] + // fn still_streaming_ui_text_with_path_and_description() { + // let input = json!({ + // "path": "src/main.rs", + // "display_description": "Fix error handling", + // "old_string": "old code", + // "new_string": "new code" + // }); + + // assert_eq!( + // EditFileTool.still_streaming_ui_text(&input), + // "Fix error handling", + // ); + // } + + // #[test] + // fn still_streaming_ui_text_no_path_or_description() { + // let input = json!({ + // "path": "", + // "display_description": "", + // "old_string": "old code", + // "new_string": "new code" + // }); + + // assert_eq!( + // EditFileTool.still_streaming_ui_text(&input), + // DEFAULT_UI_TEXT, + // ); + // } + + // #[test] + // fn still_streaming_ui_text_with_null() { + // let input = serde_json::Value::Null; + + // assert_eq!( + // EditFileTool.still_streaming_ui_text(&input), + // DEFAULT_UI_TEXT, + // ); + // } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } + + // fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { + // cx.update(|cx| { + // // Set custom data directory (config will be under data_dir/config) + // paths::set_custom_data_dir(data_dir.to_str().unwrap()); + + // let settings_store = SettingsStore::test(cx); + // cx.set_global(settings_store); + // language::init(cx); + // TelemetrySettings::register(cx); + // agent_settings::AgentSettings::register(cx); + // Project::init_settings(cx); + // }); + // } + + // #[gpui::test] + // async fn test_format_on_save(cx: &mut TestAppContext) { + // init_test(cx); + + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree("/root", json!({"src": {}})).await; + + // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // // Set up a Rust language with LSP formatting support + // let rust_language = Arc::new(language::Language::new( + // language::LanguageConfig { + // name: "Rust".into(), + // matcher: language::LanguageMatcher { + // path_suffixes: vec!["rs".to_string()], + // ..Default::default() + // }, + // ..Default::default() + // }, + // None, + // )); + + // // Register the language and fake LSP + // let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + // language_registry.add(rust_language); + + // let mut fake_language_servers = language_registry.register_fake_lsp( + // "Rust", + // language::FakeLspAdapter { + // capabilities: lsp::ServerCapabilities { + // document_formatting_provider: Some(lsp::OneOf::Left(true)), + // ..Default::default() + // }, + // ..Default::default() + // }, + // ); + + // // Create the file + // fs.save( + // path!("/root/src/main.rs").as_ref(), + // &"initial content".into(), + // language::LineEnding::Unix, + // ) + // .await + // .unwrap(); + + // // Open the buffer to trigger LSP initialization + // let buffer = project + // .update(cx, |project, cx| { + // project.open_local_buffer(path!("/root/src/main.rs"), cx) + // }) + // .await + // .unwrap(); + + // // Register the buffer with language servers + // let _handle = project.update(cx, |project, cx| { + // project.register_buffer_with_language_servers(&buffer, cx) + // }); + + // const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; + // const FORMATTED_CONTENT: &str = + // "This file was formatted by the fake formatter in the test.\n"; + + // // Get the fake language server and set up formatting handler + // let fake_language_server = fake_language_servers.next().await.unwrap(); + // fake_language_server.set_request_handler::({ + // |_, _| async move { + // Ok(Some(vec![lsp::TextEdit { + // range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), + // new_text: FORMATTED_CONTENT.to_string(), + // }])) + // } + // }); + + // let action_log = cx.new(|_| ActionLog::new(project.clone())); + // let model = Arc::new(FakeLanguageModel::default()); + + // // First, test with format_on_save enabled + // cx.update(|cx| { + // SettingsStore::update_global(cx, |store, cx| { + // store.update_user_settings::( + // cx, + // |settings| { + // settings.defaults.format_on_save = Some(FormatOnSave::On); + // settings.defaults.formatter = + // Some(language::language_settings::SelectedFormatter::Auto); + // }, + // ); + // }); + // }); + + // // Have the model stream unformatted content + // let edit_result = { + // let edit_task = cx.update(|cx| { + // let input = serde_json::to_value(EditFileToolInput { + // display_description: "Create main function".into(), + // path: "root/src/main.rs".into(), + // mode: EditFileMode::Overwrite, + // }) + // .unwrap(); + // Arc::new(EditFileTool) + // .run( + // input, + // Arc::default(), + // project.clone(), + // action_log.clone(), + // model.clone(), + // None, + // cx, + // ) + // .output + // }); + + // // Stream the unformatted content + // cx.executor().run_until_parked(); + // model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + // model.end_last_completion_stream(); + + // edit_task.await + // }; + // assert!(edit_result.is_ok()); + + // // Wait for any async operations (e.g. formatting) to complete + // cx.executor().run_until_parked(); + + // // Read the file to verify it was formatted automatically + // let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + // assert_eq!( + // // Ignore carriage returns on Windows + // new_content.replace("\r\n", "\n"), + // FORMATTED_CONTENT, + // "Code should be formatted when format_on_save is enabled" + // ); + + // let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); + + // assert_eq!( + // stale_buffer_count, 0, + // "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ + // This causes the agent to think the file was modified externally when it was just formatted.", + // stale_buffer_count + // ); + + // // Next, test with format_on_save disabled + // cx.update(|cx| { + // SettingsStore::update_global(cx, |store, cx| { + // store.update_user_settings::( + // cx, + // |settings| { + // settings.defaults.format_on_save = Some(FormatOnSave::Off); + // }, + // ); + // }); + // }); + + // // Stream unformatted edits again + // let edit_result = { + // let edit_task = cx.update(|cx| { + // let input = serde_json::to_value(EditFileToolInput { + // display_description: "Update main function".into(), + // path: "root/src/main.rs".into(), + // mode: EditFileMode::Overwrite, + // }) + // .unwrap(); + // Arc::new(EditFileTool) + // .run( + // input, + // Arc::default(), + // project.clone(), + // action_log.clone(), + // model.clone(), + // None, + // cx, + // ) + // .output + // }); + + // // Stream the unformatted content + // cx.executor().run_until_parked(); + // model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + // model.end_last_completion_stream(); + + // edit_task.await + // }; + // assert!(edit_result.is_ok()); + + // // Wait for any async operations (e.g. formatting) to complete + // cx.executor().run_until_parked(); + + // // Verify the file was not formatted + // let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + // assert_eq!( + // // Ignore carriage returns on Windows + // new_content.replace("\r\n", "\n"), + // UNFORMATTED_CONTENT, + // "Code should not be formatted when format_on_save is disabled" + // ); + // } + + // #[gpui::test] + // async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { + // init_test(cx); + + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree("/root", json!({"src": {}})).await; + + // // Create a simple file with trailing whitespace + // fs.save( + // path!("/root/src/main.rs").as_ref(), + // &"initial content".into(), + // language::LineEnding::Unix, + // ) + // .await + // .unwrap(); + + // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + // let action_log = cx.new(|_| ActionLog::new(project.clone())); + // let model = Arc::new(FakeLanguageModel::default()); + + // // First, test with remove_trailing_whitespace_on_save enabled + // cx.update(|cx| { + // SettingsStore::update_global(cx, |store, cx| { + // store.update_user_settings::( + // cx, + // |settings| { + // settings.defaults.remove_trailing_whitespace_on_save = Some(true); + // }, + // ); + // }); + // }); + + // const CONTENT_WITH_TRAILING_WHITESPACE: &str = + // "fn main() { \n println!(\"Hello!\"); \n}\n"; + + // // Have the model stream content that contains trailing whitespace + // let edit_result = { + // let edit_task = cx.update(|cx| { + // let input = serde_json::to_value(EditFileToolInput { + // display_description: "Create main function".into(), + // path: "root/src/main.rs".into(), + // mode: EditFileMode::Overwrite, + // }) + // .unwrap(); + // Arc::new(EditFileTool) + // .run( + // input, + // Arc::default(), + // project.clone(), + // action_log.clone(), + // model.clone(), + // None, + // cx, + // ) + // .output + // }); + + // // Stream the content with trailing whitespace + // cx.executor().run_until_parked(); + // model.send_last_completion_stream_text_chunk( + // CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + // ); + // model.end_last_completion_stream(); + + // edit_task.await + // }; + // assert!(edit_result.is_ok()); + + // // Wait for any async operations (e.g. formatting) to complete + // cx.executor().run_until_parked(); + + // // Read the file to verify trailing whitespace was removed automatically + // assert_eq!( + // // Ignore carriage returns on Windows + // fs.load(path!("/root/src/main.rs").as_ref()) + // .await + // .unwrap() + // .replace("\r\n", "\n"), + // "fn main() {\n println!(\"Hello!\");\n}\n", + // "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" + // ); + + // // Next, test with remove_trailing_whitespace_on_save disabled + // cx.update(|cx| { + // SettingsStore::update_global(cx, |store, cx| { + // store.update_user_settings::( + // cx, + // |settings| { + // settings.defaults.remove_trailing_whitespace_on_save = Some(false); + // }, + // ); + // }); + // }); + + // // Stream edits again with trailing whitespace + // let edit_result = { + // let edit_task = cx.update(|cx| { + // let input = serde_json::to_value(EditFileToolInput { + // display_description: "Update main function".into(), + // path: "root/src/main.rs".into(), + // mode: EditFileMode::Overwrite, + // }) + // .unwrap(); + // Arc::new(EditFileTool) + // .run( + // input, + // Arc::default(), + // project.clone(), + // action_log.clone(), + // model.clone(), + // None, + // cx, + // ) + // .output + // }); + + // // Stream the content with trailing whitespace + // cx.executor().run_until_parked(); + // model.send_last_completion_stream_text_chunk( + // CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + // ); + // model.end_last_completion_stream(); + + // edit_task.await + // }; + // assert!(edit_result.is_ok()); + + // // Wait for any async operations (e.g. formatting) to complete + // cx.executor().run_until_parked(); + + // // Verify the file still has trailing whitespace + // // Read the file again - it should still have trailing whitespace + // let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + // assert_eq!( + // // Ignore carriage returns on Windows + // final_content.replace("\r\n", "\n"), + // CONTENT_WITH_TRAILING_WHITESPACE, + // "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" + // ); + // } + + // #[gpui::test] + // async fn test_needs_confirmation(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree("/root", json!({})).await; + + // // Test 1: Path with .zed component should require confirmation + // let input_with_zed = json!({ + // "display_description": "Edit settings", + // "path": ".zed/settings.json", + // "mode": "edit" + // }); + // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_with_zed, &project, cx), + // "Path with .zed component should require confirmation" + // ); + // }); + + // // Test 2: Absolute path should require confirmation + // let input_absolute = json!({ + // "display_description": "Edit file", + // "path": "/etc/hosts", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_absolute, &project, cx), + // "Absolute path should require confirmation" + // ); + // }); + + // // Test 3: Relative path without .zed should not require confirmation + // let input_relative = json!({ + // "display_description": "Edit file", + // "path": "root/src/main.rs", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // !tool.needs_confirmation(&input_relative, &project, cx), + // "Relative path without .zed should not require confirmation" + // ); + // }); + + // // Test 4: Path with .zed in the middle should require confirmation + // let input_zed_middle = json!({ + // "display_description": "Edit settings", + // "path": "root/.zed/tasks.json", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_zed_middle, &project, cx), + // "Path with .zed in any component should require confirmation" + // ); + // }); + + // // Test 5: When always_allow_tool_actions is enabled, no confirmation needed + // cx.update(|cx| { + // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // settings.always_allow_tool_actions = true; + // agent_settings::AgentSettings::override_global(settings, cx); + + // assert!( + // !tool.needs_confirmation(&input_with_zed, &project, cx), + // "When always_allow_tool_actions is true, no confirmation should be needed" + // ); + // assert!( + // !tool.needs_confirmation(&input_absolute, &project, cx), + // "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" + // ); + // }); + // } + + // #[gpui::test] + // async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { + // // Set up a custom config directory for testing + // let temp_dir = tempfile::tempdir().unwrap(); + // init_test_with_config(cx, temp_dir.path()); + + // let tool = Arc::new(EditFileTool); + + // // Test ui_text shows context for various paths + // let test_cases = vec![ + // ( + // json!({ + // "display_description": "Update config", + // "path": ".zed/settings.json", + // "mode": "edit" + // }), + // "Update config (local settings)", + // ".zed path should show local settings context", + // ), + // ( + // json!({ + // "display_description": "Fix bug", + // "path": "src/.zed/local.json", + // "mode": "edit" + // }), + // "Fix bug (local settings)", + // "Nested .zed path should show local settings context", + // ), + // ( + // json!({ + // "display_description": "Update readme", + // "path": "README.md", + // "mode": "edit" + // }), + // "Update readme", + // "Normal path should not show additional context", + // ), + // ( + // json!({ + // "display_description": "Edit config", + // "path": "config.zed", + // "mode": "edit" + // }), + // "Edit config", + // ".zed as extension should not show context", + // ), + // ]; + + // for (input, expected_text, description) in test_cases { + // cx.update(|_cx| { + // let ui_text = tool.ui_text(&input); + // assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + // }); + // } + // } + + // #[gpui::test] + // async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + + // // Create a project in /project directory + // fs.insert_tree("/project", json!({})).await; + // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // // Test file outside project requires confirmation + // let input_outside = json!({ + // "display_description": "Edit file", + // "path": "/outside/file.txt", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_outside, &project, cx), + // "File outside project should require confirmation" + // ); + // }); + + // // Test file inside project doesn't require confirmation + // let input_inside = json!({ + // "display_description": "Edit file", + // "path": "project/file.txt", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // !tool.needs_confirmation(&input_inside, &project, cx), + // "File inside project should not require confirmation" + // ); + // }); + // } + + // #[gpui::test] + // async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { + // // Set up a custom data directory for testing + // let temp_dir = tempfile::tempdir().unwrap(); + // init_test_with_config(cx, temp_dir.path()); + + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree("/home/user/myproject", json!({})).await; + // let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; + + // // Get the actual local settings folder name + // let local_settings_folder = paths::local_settings_folder_relative_path(); + + // // Test various config path patterns + // let test_cases = vec![ + // ( + // format!("{}/settings.json", local_settings_folder.display()), + // true, + // "Top-level local settings file".to_string(), + // ), + // ( + // format!( + // "myproject/{}/settings.json", + // local_settings_folder.display() + // ), + // true, + // "Local settings in project path".to_string(), + // ), + // ( + // format!("src/{}/config.toml", local_settings_folder.display()), + // true, + // "Local settings in subdirectory".to_string(), + // ), + // ( + // ".zed.backup/file.txt".to_string(), + // true, + // ".zed.backup is outside project".to_string(), + // ), + // ( + // "my.zed/file.txt".to_string(), + // true, + // "my.zed is outside project".to_string(), + // ), + // ( + // "myproject/src/file.zed".to_string(), + // false, + // ".zed as file extension".to_string(), + // ), + // ( + // "myproject/normal/path/file.rs".to_string(), + // false, + // "Normal file without config paths".to_string(), + // ), + // ]; + + // for (path, should_confirm, description) in test_cases { + // let input = json!({ + // "display_description": "Edit file", + // "path": path, + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert_eq!( + // tool.needs_confirmation(&input, &project, cx), + // should_confirm, + // "Failed for case: {} - path: {}", + // description, + // path + // ); + // }); + // } + // } + + // #[gpui::test] + // async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { + // // Set up a custom data directory for testing + // let temp_dir = tempfile::tempdir().unwrap(); + // init_test_with_config(cx, temp_dir.path()); + + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + + // // Create test files in the global config directory + // let global_config_dir = paths::config_dir(); + // fs::create_dir_all(&global_config_dir).unwrap(); + // let global_settings_path = global_config_dir.join("settings.json"); + // fs::write(&global_settings_path, "{}").unwrap(); + + // fs.insert_tree("/project", json!({})).await; + // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // // Test global config paths + // let test_cases = vec![ + // ( + // global_settings_path.to_str().unwrap().to_string(), + // true, + // "Global settings file should require confirmation", + // ), + // ( + // global_config_dir + // .join("keymap.json") + // .to_str() + // .unwrap() + // .to_string(), + // true, + // "Global keymap file should require confirmation", + // ), + // ( + // "project/normal_file.rs".to_string(), + // false, + // "Normal project file should not require confirmation", + // ), + // ]; + + // for (path, should_confirm, description) in test_cases { + // let input = json!({ + // "display_description": "Edit file", + // "path": path, + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert_eq!( + // tool.needs_confirmation(&input, &project, cx), + // should_confirm, + // "Failed for case: {}", + // description + // ); + // }); + // } + // } + + // #[gpui::test] + // async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + + // // Create multiple worktree directories + // fs.insert_tree( + // "/workspace/frontend", + // json!({ + // "src": { + // "main.js": "console.log('frontend');" + // } + // }), + // ) + // .await; + // fs.insert_tree( + // "/workspace/backend", + // json!({ + // "src": { + // "main.rs": "fn main() {}" + // } + // }), + // ) + // .await; + // fs.insert_tree( + // "/workspace/shared", + // json!({ + // ".zed": { + // "settings.json": "{}" + // } + // }), + // ) + // .await; + + // // Create project with multiple worktrees + // let project = Project::test( + // fs.clone(), + // [ + // path!("/workspace/frontend").as_ref(), + // path!("/workspace/backend").as_ref(), + // path!("/workspace/shared").as_ref(), + // ], + // cx, + // ) + // .await; + + // // Test files in different worktrees + // let test_cases = vec![ + // ("frontend/src/main.js", false, "File in first worktree"), + // ("backend/src/main.rs", false, "File in second worktree"), + // ( + // "shared/.zed/settings.json", + // true, + // ".zed file in third worktree", + // ), + // ("/etc/hosts", true, "Absolute path outside all worktrees"), + // ( + // "../outside/file.txt", + // true, + // "Relative path outside worktrees", + // ), + // ]; + + // for (path, should_confirm, description) in test_cases { + // let input = json!({ + // "display_description": "Edit file", + // "path": path, + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert_eq!( + // tool.needs_confirmation(&input, &project, cx), + // should_confirm, + // "Failed for case: {} - path: {}", + // description, + // path + // ); + // }); + // } + // } + + // #[gpui::test] + // async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree( + // "/project", + // json!({ + // ".zed": { + // "settings.json": "{}" + // }, + // "src": { + // ".zed": { + // "local.json": "{}" + // } + // } + // }), + // ) + // .await; + // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // // Test edge cases + // let test_cases = vec![ + // // Empty path - find_project_path returns Some for empty paths + // ("", false, "Empty path is treated as project root"), + // // Root directory + // ("/", true, "Root directory should be outside project"), + // // Parent directory references - find_project_path resolves these + // ( + // "project/../other", + // false, + // "Path with .. is resolved by find_project_path", + // ), + // ( + // "project/./src/file.rs", + // false, + // "Path with . should work normally", + // ), + // // Windows-style paths (if on Windows) + // #[cfg(target_os = "windows")] + // ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + // #[cfg(target_os = "windows")] + // ("project\\src\\main.rs", false, "Windows-style project path"), + // ]; + + // for (path, should_confirm, description) in test_cases { + // let input = json!({ + // "display_description": "Edit file", + // "path": path, + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert_eq!( + // tool.needs_confirmation(&input, &project, cx), + // should_confirm, + // "Failed for case: {} - path: {}", + // description, + // path + // ); + // }); + // } + // } + + // #[gpui::test] + // async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + + // // Test UI text for various scenarios + // let test_cases = vec![ + // ( + // json!({ + // "display_description": "Update config", + // "path": ".zed/settings.json", + // "mode": "edit" + // }), + // "Update config (local settings)", + // ".zed path should show local settings context", + // ), + // ( + // json!({ + // "display_description": "Fix bug", + // "path": "src/.zed/local.json", + // "mode": "edit" + // }), + // "Fix bug (local settings)", + // "Nested .zed path should show local settings context", + // ), + // ( + // json!({ + // "display_description": "Update readme", + // "path": "README.md", + // "mode": "edit" + // }), + // "Update readme", + // "Normal path should not show additional context", + // ), + // ( + // json!({ + // "display_description": "Edit config", + // "path": "config.zed", + // "mode": "edit" + // }), + // "Edit config", + // ".zed as extension should not show context", + // ), + // ]; + + // for (input, expected_text, description) in test_cases { + // cx.update(|_cx| { + // let ui_text = tool.ui_text(&input); + // assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + // }); + // } + // } + + // #[gpui::test] + // async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + // init_test(cx); + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree( + // "/project", + // json!({ + // "existing.txt": "content", + // ".zed": { + // "settings.json": "{}" + // } + // }), + // ) + // .await; + // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // // Test different EditFileMode values + // let modes = vec![ + // EditFileMode::Edit, + // EditFileMode::Create, + // EditFileMode::Overwrite, + // ]; + + // for mode in modes { + // // Test .zed path with different modes + // let input_zed = json!({ + // "display_description": "Edit settings", + // "path": "project/.zed/settings.json", + // "mode": mode + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_zed, &project, cx), + // ".zed path should require confirmation regardless of mode: {:?}", + // mode + // ); + // }); + + // // Test outside path with different modes + // let input_outside = json!({ + // "display_description": "Edit file", + // "path": "/outside/file.txt", + // "mode": mode + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input_outside, &project, cx), + // "Outside path should require confirmation regardless of mode: {:?}", + // mode + // ); + // }); + + // // Test normal path with different modes + // let input_normal = json!({ + // "display_description": "Edit file", + // "path": "project/normal.txt", + // "mode": mode + // }); + // cx.update(|cx| { + // assert!( + // !tool.needs_confirmation(&input_normal, &project, cx), + // "Normal path should not require confirmation regardless of mode: {:?}", + // mode + // ); + // }); + // } + // } + + // #[gpui::test] + // async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { + // // Set up with custom directories for deterministic testing + // let temp_dir = tempfile::tempdir().unwrap(); + // init_test_with_config(cx, temp_dir.path()); + + // let tool = Arc::new(EditFileTool); + // let fs = project::FakeFs::new(cx.executor()); + // fs.insert_tree("/project", json!({})).await; + // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // // Enable always_allow_tool_actions + // cx.update(|cx| { + // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // settings.always_allow_tool_actions = true; + // agent_settings::AgentSettings::override_global(settings, cx); + // }); + + // // Test that all paths that normally require confirmation are bypassed + // let global_settings_path = paths::config_dir().join("settings.json"); + // fs::create_dir_all(paths::config_dir()).unwrap(); + // fs::write(&global_settings_path, "{}").unwrap(); + + // let test_cases = vec![ + // ".zed/settings.json", + // "project/.zed/config.toml", + // global_settings_path.to_str().unwrap(), + // "/etc/hosts", + // "/absolute/path/file.txt", + // "../outside/project.txt", + // ]; + + // for path in test_cases { + // let input = json!({ + // "display_description": "Edit file", + // "path": path, + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // !tool.needs_confirmation(&input, &project, cx), + // "Path {} should not require confirmation when always_allow_tool_actions is true", + // path + // ); + // }); + // } + + // // Disable always_allow_tool_actions and verify confirmation is required again + // cx.update(|cx| { + // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // settings.always_allow_tool_actions = false; + // agent_settings::AgentSettings::override_global(settings, cx); + // }); + + // // Verify .zed path requires confirmation again + // let input = json!({ + // "display_description": "Edit file", + // "path": ".zed/settings.json", + // "mode": "edit" + // }); + // cx.update(|cx| { + // assert!( + // tool.needs_confirmation(&input, &project, cx), + // ".zed path should require confirmation when always_allow_tool_actions is false" + // ); + // }); + // } +} diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 30794ccdad..27419a452e 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -285,8 +285,6 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { - use crate::TestToolCallEventStream; - use super::*; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher}; @@ -304,7 +302,7 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); + let (event_stream, _) = ToolCallEventStream::test(); let result = cx .update(|cx| { @@ -313,7 +311,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, event_stream, cx) }) .await; assert_eq!( @@ -321,6 +319,7 @@ mod test { "root/nonexistent_file.txt not found" ); } + #[gpui::test] async fn test_read_small_file(cx: &mut TestAppContext) { init_test(cx); @@ -336,7 +335,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -344,7 +342,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!(result.unwrap(), "This is a small file content"); @@ -367,7 +365,6 @@ mod test { language_registry.add(Arc::new(rust_lang())); let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); let content = cx .update(|cx| { let input = ReadFileToolInput { @@ -375,7 +372,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); @@ -399,7 +396,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; let content = result.unwrap(); @@ -438,7 +435,6 @@ mod test { let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -446,7 +442,7 @@ mod test { start_line: Some(2), end_line: Some(4), }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4"); @@ -467,7 +463,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); // start_line of 0 should be treated as 1 let result = cx @@ -477,7 +472,7 @@ mod test { start_line: Some(0), end_line: Some(2), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!(result.unwrap(), "Line 1\nLine 2"); @@ -490,7 +485,7 @@ mod test { start_line: Some(1), end_line: Some(0), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!(result.unwrap(), "Line 1"); @@ -503,7 +498,7 @@ mod test { start_line: Some(3), end_line: Some(2), }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!(result.unwrap(), "Line 3"); @@ -612,7 +607,6 @@ mod test { let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let event_stream = TestToolCallEventStream::new(); // Reading a file outside the project worktree should fail let result = cx @@ -622,7 +616,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -638,7 +632,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -654,7 +648,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -669,7 +663,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -685,7 +679,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -700,7 +694,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -715,7 +709,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -731,7 +725,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; assert!(result.is_ok(), "Should be able to read normal files"); @@ -745,7 +739,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream.stream(), cx) + tool.run(input, ToolCallEventStream::test().0, cx) }) .await; assert!( @@ -826,7 +820,6 @@ mod test { let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone())); - let event_stream = TestToolCallEventStream::new(); // Test reading allowed files in worktree1 let result = cx @@ -836,7 +829,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); @@ -851,7 +844,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -872,7 +865,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -893,7 +886,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await .unwrap(); @@ -911,7 +904,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -932,7 +925,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; @@ -954,7 +947,7 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, event_stream.stream(), cx) + tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; From e06f54054a17c1dda2b17b63a4340c8d237e1506 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 8 Aug 2025 12:24:11 +0200 Subject: [PATCH 17/22] Introduce a `AgentTool::Output` associated type Co-authored-by: Ben Brandt --- crates/agent2/src/tests/test_tools.rs | 5 ++ crates/agent2/src/thread.rs | 32 ++++++++---- crates/agent2/src/tools/edit_file_tool.rs | 59 +++++++++++++++------ crates/agent2/src/tools/find_path_tool.rs | 63 ++++++++++++++--------- crates/agent2/src/tools/read_file_tool.rs | 1 + crates/agent2/src/tools/thinking_tool.rs | 1 + 6 files changed, 110 insertions(+), 51 deletions(-) diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent2/src/tests/test_tools.rs index f5030994e0..d22ff6ace8 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent2/src/tests/test_tools.rs @@ -14,6 +14,7 @@ pub struct EchoTool; impl AgentTool for EchoTool { type Input = EchoToolInput; + type Output = String; fn name(&self) -> SharedString { "echo".into() @@ -48,6 +49,7 @@ pub struct DelayTool; impl AgentTool for DelayTool { type Input = DelayToolInput; + type Output = String; fn name(&self) -> SharedString { "delay".into() @@ -84,6 +86,7 @@ pub struct ToolRequiringPermission; impl AgentTool for ToolRequiringPermission { type Input = ToolRequiringPermissionInput; + type Output = String; fn name(&self) -> SharedString { "tool_requiring_permission".into() @@ -118,6 +121,7 @@ pub struct InfiniteTool; impl AgentTool for InfiniteTool { type Input = InfiniteToolInput; + type Output = String; fn name(&self) -> SharedString { "infinite".into() @@ -168,6 +172,7 @@ pub struct WordListTool; impl AgentTool for WordListTool { type Input = WordListInput; + type Output = String; fn name(&self) -> SharedString { "word_list".into() diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 31ace16124..e7df7b3b22 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -517,8 +517,8 @@ impl Thread { tool_use_id: tool_use.id, tool_name: tool_use.name, is_error: false, - content: LanguageModelToolResultContent::Text(Arc::from(tool_output)), - output: None, + content: tool_output.llm_output, + output: Some(tool_output.raw_output), }, Err(error) => LanguageModelToolResult { tool_use_id: tool_use.id, @@ -664,6 +664,7 @@ where Self: 'static + Sized, { type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; + type Output: for<'de> Deserialize<'de> + Serialize + Into; fn name(&self) -> SharedString; @@ -693,7 +694,7 @@ where input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task>; + ) -> Task>; fn erase(self) -> Arc { Arc::new(Erased(Arc::new(self))) @@ -702,6 +703,11 @@ where pub struct Erased(T); +pub struct AgentToolOutput { + llm_output: LanguageModelToolResultContent, + raw_output: serde_json::Value, +} + pub trait AnyAgentTool { fn name(&self) -> SharedString; fn description(&self, cx: &mut App) -> SharedString; @@ -713,7 +719,7 @@ pub trait AnyAgentTool { input: serde_json::Value, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task>; + ) -> Task>; } impl AnyAgentTool for Erased> @@ -748,12 +754,18 @@ where input: serde_json::Value, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { - let parsed_input: Result = serde_json::from_value(input).map_err(Into::into); - match parsed_input { - Ok(input) => self.0.clone().run(input, event_stream, cx), - Err(error) => Task::ready(Err(anyhow!(error))), - } + ) -> Task> { + cx.spawn(async move |cx| { + let input = serde_json::from_value(input)?; + let output = cx + .update(|cx| self.0.clone().run(input, event_stream, cx))? + .await?; + let raw_output = serde_json::to_value(&output)?; + Ok(AgentToolOutput { + llm_output: output.into(), + raw_output, + }) + }) } } diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 3cb325f459..4c4365519d 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -1,12 +1,13 @@ use acp_thread::Diff; use agent_client_protocol as acp; use anyhow::{anyhow, Context as _, Result}; -use assistant_tools::edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat}; +use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; use cloud_llm_client::CompletionIntent; use collections::HashSet; use gpui::{App, AppContext, AsyncApp, Entity, Task}; use indoc::formatdoc; use language::language_settings::{self, FormatOnSave}; +use language_model::LanguageModelToolResultContent; use paths; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{Project, ProjectPath}; @@ -85,6 +86,31 @@ pub enum EditFileMode { Overwrite, } +#[derive(Debug, Serialize, Deserialize)] +pub struct EditFileToolOutput { + input_path: PathBuf, + project_path: PathBuf, + new_text: String, + old_text: Arc, + diff: String, + edit_agent_output: EditAgentOutput, +} + +impl From for LanguageModelToolResultContent { + fn from(output: EditFileToolOutput) -> Self { + if output.diff.is_empty() { + "No edits were made.".into() + } else { + format!( + "Edited {}:\n\n```diff\n{}\n```", + output.input_path.display(), + output.diff + ) + .into() + } + } +} + pub struct EditFileTool { thread: Entity, } @@ -146,6 +172,7 @@ impl EditFileTool { impl AgentTool for EditFileTool { type Input = EditFileToolInput; + type Output = EditFileToolOutput; fn name(&self) -> SharedString { "edit_file".into() @@ -164,7 +191,7 @@ impl AgentTool for EditFileTool { input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { + ) -> Task> { let project = self.thread.read(cx).project().clone(); let project_path = match resolve_path(&input, project.clone(), cx) { Ok(path) => path, @@ -259,7 +286,7 @@ impl AgentTool for EditFileTool { }) .unwrap_or(false); - let _ = output.await?; + let edit_agent_output = output.await?; if format_on_save_enabled { action_log.update(cx, |log, cx| { @@ -287,22 +314,19 @@ impl AgentTool for EditFileTool { })?; let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let unified_diff = cx + let (new_text, unified_diff) = cx .background_spawn({ let new_snapshot = new_snapshot.clone(); let old_text = old_text.clone(); async move { let new_text = new_snapshot.text(); - language::unified_diff(&old_text, &new_text) + let diff = language::unified_diff(&old_text, &new_text); + (new_text, diff) } }) .await; - println!("\n\n{}\n\n", unified_diff); - - diff.update(cx, |diff, cx| { - diff.finalize(cx); - }).ok(); + diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); let input_path = input.path.display(); if unified_diff.is_empty() { @@ -329,13 +353,16 @@ impl AgentTool for EditFileTool { "} } ); - Ok("No edits were made.".into()) - } else { - Ok(format!( - "Edited {}:\n\n```diff\n{}\n```", - input_path, unified_diff - )) } + + Ok(EditFileToolOutput { + input_path: input.path, + project_path: project_path.path.to_path_buf(), + new_text: new_text.clone(), + old_text, + diff: unified_diff, + edit_agent_output, + }) }) } } diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index e840fec78c..24bdcded8c 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -1,6 +1,8 @@ +use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol as acp; use anyhow::{anyhow, Result}; use gpui::{App, AppContext, Entity, SharedString, Task}; +use language_model::LanguageModelToolResultContent; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,8 +10,6 @@ use std::fmt::Write; use std::{cmp, path::PathBuf, sync::Arc}; use util::paths::PathMatcher; -use crate::{AgentTool, ToolCallEventStream}; - /// Fast file path pattern matching tool that works with any codebase size /// /// - Supports glob patterns like "**/*.js" or "src/**/*.ts" @@ -39,8 +39,35 @@ pub struct FindPathToolInput { } #[derive(Debug, Serialize, Deserialize)] -struct FindPathToolOutput { - paths: Vec, +pub struct FindPathToolOutput { + offset: usize, + current_matches_page: Vec, + all_matches_len: usize, +} + +impl From for LanguageModelToolResultContent { + fn from(output: FindPathToolOutput) -> Self { + if output.current_matches_page.is_empty() { + "No matches found".into() + } else { + let mut llm_output = format!("Found {} total matches.", output.all_matches_len); + if output.all_matches_len > RESULTS_PER_PAGE { + write!( + &mut llm_output, + "\nShowing results {}-{} (provide 'offset' parameter for more results):", + output.offset + 1, + output.offset + output.current_matches_page.len() + ) + .unwrap(); + } + + for mat in output.current_matches_page { + write!(&mut llm_output, "\n{}", mat.display()).unwrap(); + } + + llm_output.into() + } + } } const RESULTS_PER_PAGE: usize = 50; @@ -57,6 +84,7 @@ impl FindPathTool { impl AgentTool for FindPathTool { type Input = FindPathToolInput; + type Output = FindPathToolOutput; fn name(&self) -> SharedString { "find_path".into() @@ -75,7 +103,7 @@ impl AgentTool for FindPathTool { input: Self::Input, event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { + ) -> Task> { let search_paths_task = search_paths(&input.glob, self.project.clone(), cx); cx.background_spawn(async move { @@ -113,26 +141,11 @@ impl AgentTool for FindPathTool { ..Default::default() }); - if matches.is_empty() { - Ok("No matches found".into()) - } else { - let mut message = format!("Found {} total matches.", matches.len()); - if matches.len() > RESULTS_PER_PAGE { - write!( - &mut message, - "\nShowing results {}-{} (provide 'offset' parameter for more results):", - input.offset + 1, - input.offset + paginated_matches.len() - ) - .unwrap(); - } - - for mat in matches.iter().skip(input.offset).take(RESULTS_PER_PAGE) { - write!(&mut message, "\n{}", mat.display()).unwrap(); - } - - Ok(message) - } + Ok(FindPathToolOutput { + offset: input.offset, + current_matches_page: paginated_matches.to_vec(), + all_matches_len: matches.len(), + }) }) } } diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 27419a452e..f85efed286 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -59,6 +59,7 @@ impl ReadFileTool { impl AgentTool for ReadFileTool { type Input = ReadFileToolInput; + type Output = String; fn name(&self) -> SharedString { "read_file".into() diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent2/src/tools/thinking_tool.rs index bb85d8eceb..d85370e7e5 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent2/src/tools/thinking_tool.rs @@ -20,6 +20,7 @@ pub struct ThinkingTool; impl AgentTool for ThinkingTool { type Input = ThinkingToolInput; + type Output = String; fn name(&self) -> SharedString { "thinking".into() From 63b625236d2942ccbe881b93e31b4efa52fce763 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 8 Aug 2025 13:20:38 +0200 Subject: [PATCH 18/22] More tests --- Cargo.lock | 1 + crates/agent2/Cargo.toml | 1 + crates/agent2/src/tools/edit_file_tool.rs | 285 +++++++------------ crates/assistant_tools/src/edit_file_tool.rs | 85 ------ 4 files changed, 111 insertions(+), 261 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ba60f44f2..65827d428b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ dependencies = [ "language_model", "language_models", "log", + "lsp", "paths", "pretty_assertions", "project", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 862f6a96b1..3e19895a31 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -56,6 +56,7 @@ gpui = { workspace = true, "features" = ["test-support"] } gpui_tokio.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } +lsp = { workspace = true, "features" = ["test-support"] } project = { workspace = true, "features" = ["test-support"] } reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 4c4365519d..8c96c248d5 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -195,7 +195,7 @@ impl AgentTool for EditFileTool { let project = self.thread.read(cx).project().clone(); let project_path = match resolve_path(&input, project.clone(), cx) { Ok(path) => path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), + Err(err) => return Task::ready(Err(anyhow!(err))), }; let request = self.thread.update(cx, |thread, cx| { @@ -473,189 +473,97 @@ mod tests { ); } - // #[gpui::test] - // async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { - // let mode = &EditFileMode::Create; + #[gpui::test] + async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { + let mode = &EditFileMode::Create; - // let result = test_resolve_path(mode, "root/new.txt", cx); - // assert_resolved_path_eq(result.await, "new.txt"); + let result = test_resolve_path(mode, "root/new.txt", cx); + assert_resolved_path_eq(result.await, "new.txt"); - // let result = test_resolve_path(mode, "new.txt", cx); - // assert_resolved_path_eq(result.await, "new.txt"); + let result = test_resolve_path(mode, "new.txt", cx); + assert_resolved_path_eq(result.await, "new.txt"); - // let result = test_resolve_path(mode, "dir/new.txt", cx); - // assert_resolved_path_eq(result.await, "dir/new.txt"); + let result = test_resolve_path(mode, "dir/new.txt", cx); + assert_resolved_path_eq(result.await, "dir/new.txt"); - // let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); - // assert_eq!( - // result.await.unwrap_err().to_string(), - // "Can't create file: file already exists" - // ); + let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: file already exists" + ); - // let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); - // assert_eq!( - // result.await.unwrap_err().to_string(), - // "Can't create file: parent directory doesn't exist" - // ); - // } - - // #[gpui::test] - // async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { - // let mode = &EditFileMode::Edit; - - // let path_with_root = "root/dir/subdir/existing.txt"; - // let path_without_root = "dir/subdir/existing.txt"; - // let result = test_resolve_path(mode, path_with_root, cx); - // assert_resolved_path_eq(result.await, path_without_root); - - // let result = test_resolve_path(mode, path_without_root, cx); - // assert_resolved_path_eq(result.await, path_without_root); - - // let result = test_resolve_path(mode, "root/nonexistent.txt", cx); - // assert_eq!( - // result.await.unwrap_err().to_string(), - // "Can't edit file: path not found" - // ); - - // let result = test_resolve_path(mode, "root/dir", cx); - // assert_eq!( - // result.await.unwrap_err().to_string(), - // "Can't edit file: path is a directory" - // ); - // } - - // async fn test_resolve_path( - // mode: &EditFileMode, - // path: &str, - // cx: &mut TestAppContext, - // ) -> anyhow::Result { - // init_test(cx); - - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree( - // "/root", - // json!({ - // "dir": { - // "subdir": { - // "existing.txt": "hello" - // } - // } - // }), - // ) - // .await; - // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // let input = EditFileToolInput { - // display_description: "Some edit".into(), - // path: path.into(), - // mode: mode.clone(), - // }; - - // let result = cx.update(|cx| resolve_path(&input, project, cx)); - // result - // } - - // fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { - // let actual = path - // .expect("Should return valid path") - // .path - // .to_str() - // .unwrap() - // .replace("\\", "/"); // Naive Windows paths normalization - // assert_eq!(actual, expected); - // } - - // #[test] - // fn still_streaming_ui_text_with_path() { - // let input = json!({ - // "path": "src/main.rs", - // "display_description": "", - // "old_string": "old code", - // "new_string": "new code" - // }); - - // assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - // } - - // #[test] - // fn still_streaming_ui_text_with_description() { - // let input = json!({ - // "path": "", - // "display_description": "Fix error handling", - // "old_string": "old code", - // "new_string": "new code" - // }); - - // assert_eq!( - // EditFileTool.still_streaming_ui_text(&input), - // "Fix error handling", - // ); - // } - - // #[test] - // fn still_streaming_ui_text_with_path_and_description() { - // let input = json!({ - // "path": "src/main.rs", - // "display_description": "Fix error handling", - // "old_string": "old code", - // "new_string": "new code" - // }); - - // assert_eq!( - // EditFileTool.still_streaming_ui_text(&input), - // "Fix error handling", - // ); - // } - - // #[test] - // fn still_streaming_ui_text_no_path_or_description() { - // let input = json!({ - // "path": "", - // "display_description": "", - // "old_string": "old code", - // "new_string": "new code" - // }); - - // assert_eq!( - // EditFileTool.still_streaming_ui_text(&input), - // DEFAULT_UI_TEXT, - // ); - // } - - // #[test] - // fn still_streaming_ui_text_with_null() { - // let input = serde_json::Value::Null; - - // assert_eq!( - // EditFileTool.still_streaming_ui_text(&input), - // DEFAULT_UI_TEXT, - // ); - // } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); + let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: parent directory doesn't exist" + ); } - // fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { - // cx.update(|cx| { - // // Set custom data directory (config will be under data_dir/config) - // paths::set_custom_data_dir(data_dir.to_str().unwrap()); + #[gpui::test] + async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { + let mode = &EditFileMode::Edit; - // let settings_store = SettingsStore::test(cx); - // cx.set_global(settings_store); - // language::init(cx); - // TelemetrySettings::register(cx); - // agent_settings::AgentSettings::register(cx); - // Project::init_settings(cx); - // }); - // } + let path_with_root = "root/dir/subdir/existing.txt"; + let path_without_root = "dir/subdir/existing.txt"; + let result = test_resolve_path(mode, path_with_root, cx); + assert_resolved_path_eq(result.await, path_without_root); + + let result = test_resolve_path(mode, path_without_root, cx); + assert_resolved_path_eq(result.await, path_without_root); + + let result = test_resolve_path(mode, "root/nonexistent.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path not found" + ); + + let result = test_resolve_path(mode, "root/dir", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path is a directory" + ); + } + + async fn test_resolve_path( + mode: &EditFileMode, + path: &str, + cx: &mut TestAppContext, + ) -> anyhow::Result { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir": { + "subdir": { + "existing.txt": "hello" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let input = EditFileToolInput { + display_description: "Some edit".into(), + path: path.into(), + mode: mode.clone(), + }; + + let result = cx.update(|cx| resolve_path(&input, project, cx)); + result + } + + fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { + let actual = path + .expect("Should return valid path") + .path + .to_str() + .unwrap() + .replace("\\", "/"); // Naive Windows paths normalization + assert_eq!(actual, expected); + } // #[gpui::test] // async fn test_format_on_save(cx: &mut TestAppContext) { @@ -1633,4 +1541,29 @@ mod tests { // ); // }); // } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } + + fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { + cx.update(|cx| { + // Set custom data directory (config will be under data_dir/config) + paths::set_custom_data_dir(data_dir.to_str().unwrap()); + + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index dce9f49abd..311521019d 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -120,8 +120,6 @@ struct PartialInput { display_description: String, } -const DEFAULT_UI_TEXT: &str = "Editing file"; - impl Tool for EditFileTool { fn name(&self) -> String { "edit_file".into() @@ -211,22 +209,6 @@ impl Tool for EditFileTool { } } - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - if let Some(input) = serde_json::from_value::(input.clone()).ok() { - let description = input.display_description.trim(); - if !description.is_empty() { - return description.to_string(); - } - - let path = input.path.trim(); - if !path.is_empty() { - return path.to_string(); - } - } - - DEFAULT_UI_TEXT.to_string() - } - fn run( self: Arc, input: serde_json::Value, @@ -1370,73 +1352,6 @@ mod tests { assert_eq!(actual, expected); } - #[test] - fn still_streaming_ui_text_with_path() { - let input = json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - } - - #[test] - fn still_streaming_ui_text_with_description() { - let input = json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_with_path_and_description() { - let input = json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_no_path_or_description() { - let input = json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - #[test] - fn still_streaming_ui_text_with_null() { - let input = serde_json::Value::Null; - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); From 970b5fe06e6dc6c6bcc8b67d713b6cc291def409 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 8 Aug 2025 13:59:05 +0200 Subject: [PATCH 19/22] Get tests working Co-authored-by: Antonio Scandurra --- crates/agent2/src/thread.rs | 40 +- crates/agent2/src/tools/edit_file_tool.rs | 1732 +++++++++------------ crates/assistant_tools/src/edit_agent.rs | 6 - 3 files changed, 776 insertions(+), 1002 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index e7df7b3b22..e60387bd44 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -920,10 +920,7 @@ pub struct ToolCallEventStream { impl ToolCallEventStream { #[cfg(test)] - pub fn test() -> ( - Self, - mpsc::UnboundedReceiver>, - ) { + pub fn test() -> (Self, ToolCallEventStreamReceiver) { let (events_tx, events_rx) = mpsc::unbounded::>(); @@ -939,7 +936,7 @@ impl ToolCallEventStream { AgentResponseEventStream(events_tx), ); - (stream, events_rx) + (stream, ToolCallEventStreamReceiver(events_rx)) } fn new( @@ -975,3 +972,36 @@ impl ToolCallEventStream { ) } } + +#[cfg(test)] +pub struct ToolCallEventStreamReceiver( + mpsc::UnboundedReceiver>, +); + +#[cfg(test)] +impl ToolCallEventStreamReceiver { + pub async fn expect_tool_authorization(&mut self) -> ToolCallAuthorization { + let event = self.0.next().await; + if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event { + auth + } else { + panic!("Expected ToolCallAuthorization but got: {:?}", event); + } + } +} + +#[cfg(test)] +impl std::ops::Deref for ToolCallEventStreamReceiver { + type Target = mpsc::UnboundedReceiver>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +impl std::ops::DerefMut for ToolCallEventStreamReceiver { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 8c96c248d5..2cd4cd5a96 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -439,7 +439,8 @@ mod tests { use super::*; use assistant_tool::ActionLog; use client::TelemetrySettings; - use gpui::TestAppContext; + use fs::Fs; + use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; use settings::SettingsStore; @@ -565,982 +566,745 @@ mod tests { assert_eq!(actual, expected); } - // #[gpui::test] - // async fn test_format_on_save(cx: &mut TestAppContext) { - // init_test(cx); - - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree("/root", json!({"src": {}})).await; - - // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // // Set up a Rust language with LSP formatting support - // let rust_language = Arc::new(language::Language::new( - // language::LanguageConfig { - // name: "Rust".into(), - // matcher: language::LanguageMatcher { - // path_suffixes: vec!["rs".to_string()], - // ..Default::default() - // }, - // ..Default::default() - // }, - // None, - // )); - - // // Register the language and fake LSP - // let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - // language_registry.add(rust_language); - - // let mut fake_language_servers = language_registry.register_fake_lsp( - // "Rust", - // language::FakeLspAdapter { - // capabilities: lsp::ServerCapabilities { - // document_formatting_provider: Some(lsp::OneOf::Left(true)), - // ..Default::default() - // }, - // ..Default::default() - // }, - // ); - - // // Create the file - // fs.save( - // path!("/root/src/main.rs").as_ref(), - // &"initial content".into(), - // language::LineEnding::Unix, - // ) - // .await - // .unwrap(); - - // // Open the buffer to trigger LSP initialization - // let buffer = project - // .update(cx, |project, cx| { - // project.open_local_buffer(path!("/root/src/main.rs"), cx) - // }) - // .await - // .unwrap(); - - // // Register the buffer with language servers - // let _handle = project.update(cx, |project, cx| { - // project.register_buffer_with_language_servers(&buffer, cx) - // }); - - // const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; - // const FORMATTED_CONTENT: &str = - // "This file was formatted by the fake formatter in the test.\n"; - - // // Get the fake language server and set up formatting handler - // let fake_language_server = fake_language_servers.next().await.unwrap(); - // fake_language_server.set_request_handler::({ - // |_, _| async move { - // Ok(Some(vec![lsp::TextEdit { - // range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - // new_text: FORMATTED_CONTENT.to_string(), - // }])) - // } - // }); - - // let action_log = cx.new(|_| ActionLog::new(project.clone())); - // let model = Arc::new(FakeLanguageModel::default()); - - // // First, test with format_on_save enabled - // cx.update(|cx| { - // SettingsStore::update_global(cx, |store, cx| { - // store.update_user_settings::( - // cx, - // |settings| { - // settings.defaults.format_on_save = Some(FormatOnSave::On); - // settings.defaults.formatter = - // Some(language::language_settings::SelectedFormatter::Auto); - // }, - // ); - // }); - // }); - - // // Have the model stream unformatted content - // let edit_result = { - // let edit_task = cx.update(|cx| { - // let input = serde_json::to_value(EditFileToolInput { - // display_description: "Create main function".into(), - // path: "root/src/main.rs".into(), - // mode: EditFileMode::Overwrite, - // }) - // .unwrap(); - // Arc::new(EditFileTool) - // .run( - // input, - // Arc::default(), - // project.clone(), - // action_log.clone(), - // model.clone(), - // None, - // cx, - // ) - // .output - // }); - - // // Stream the unformatted content - // cx.executor().run_until_parked(); - // model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - // model.end_last_completion_stream(); - - // edit_task.await - // }; - // assert!(edit_result.is_ok()); - - // // Wait for any async operations (e.g. formatting) to complete - // cx.executor().run_until_parked(); - - // // Read the file to verify it was formatted automatically - // let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - // assert_eq!( - // // Ignore carriage returns on Windows - // new_content.replace("\r\n", "\n"), - // FORMATTED_CONTENT, - // "Code should be formatted when format_on_save is enabled" - // ); - - // let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - // assert_eq!( - // stale_buffer_count, 0, - // "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ - // This causes the agent to think the file was modified externally when it was just formatted.", - // stale_buffer_count - // ); - - // // Next, test with format_on_save disabled - // cx.update(|cx| { - // SettingsStore::update_global(cx, |store, cx| { - // store.update_user_settings::( - // cx, - // |settings| { - // settings.defaults.format_on_save = Some(FormatOnSave::Off); - // }, - // ); - // }); - // }); - - // // Stream unformatted edits again - // let edit_result = { - // let edit_task = cx.update(|cx| { - // let input = serde_json::to_value(EditFileToolInput { - // display_description: "Update main function".into(), - // path: "root/src/main.rs".into(), - // mode: EditFileMode::Overwrite, - // }) - // .unwrap(); - // Arc::new(EditFileTool) - // .run( - // input, - // Arc::default(), - // project.clone(), - // action_log.clone(), - // model.clone(), - // None, - // cx, - // ) - // .output - // }); - - // // Stream the unformatted content - // cx.executor().run_until_parked(); - // model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - // model.end_last_completion_stream(); - - // edit_task.await - // }; - // assert!(edit_result.is_ok()); - - // // Wait for any async operations (e.g. formatting) to complete - // cx.executor().run_until_parked(); - - // // Verify the file was not formatted - // let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - // assert_eq!( - // // Ignore carriage returns on Windows - // new_content.replace("\r\n", "\n"), - // UNFORMATTED_CONTENT, - // "Code should not be formatted when format_on_save is disabled" - // ); - // } - - // #[gpui::test] - // async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { - // init_test(cx); - - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree("/root", json!({"src": {}})).await; - - // // Create a simple file with trailing whitespace - // fs.save( - // path!("/root/src/main.rs").as_ref(), - // &"initial content".into(), - // language::LineEnding::Unix, - // ) - // .await - // .unwrap(); - - // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - // let action_log = cx.new(|_| ActionLog::new(project.clone())); - // let model = Arc::new(FakeLanguageModel::default()); - - // // First, test with remove_trailing_whitespace_on_save enabled - // cx.update(|cx| { - // SettingsStore::update_global(cx, |store, cx| { - // store.update_user_settings::( - // cx, - // |settings| { - // settings.defaults.remove_trailing_whitespace_on_save = Some(true); - // }, - // ); - // }); - // }); - - // const CONTENT_WITH_TRAILING_WHITESPACE: &str = - // "fn main() { \n println!(\"Hello!\"); \n}\n"; - - // // Have the model stream content that contains trailing whitespace - // let edit_result = { - // let edit_task = cx.update(|cx| { - // let input = serde_json::to_value(EditFileToolInput { - // display_description: "Create main function".into(), - // path: "root/src/main.rs".into(), - // mode: EditFileMode::Overwrite, - // }) - // .unwrap(); - // Arc::new(EditFileTool) - // .run( - // input, - // Arc::default(), - // project.clone(), - // action_log.clone(), - // model.clone(), - // None, - // cx, - // ) - // .output - // }); - - // // Stream the content with trailing whitespace - // cx.executor().run_until_parked(); - // model.send_last_completion_stream_text_chunk( - // CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - // ); - // model.end_last_completion_stream(); - - // edit_task.await - // }; - // assert!(edit_result.is_ok()); - - // // Wait for any async operations (e.g. formatting) to complete - // cx.executor().run_until_parked(); - - // // Read the file to verify trailing whitespace was removed automatically - // assert_eq!( - // // Ignore carriage returns on Windows - // fs.load(path!("/root/src/main.rs").as_ref()) - // .await - // .unwrap() - // .replace("\r\n", "\n"), - // "fn main() {\n println!(\"Hello!\");\n}\n", - // "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - // ); - - // // Next, test with remove_trailing_whitespace_on_save disabled - // cx.update(|cx| { - // SettingsStore::update_global(cx, |store, cx| { - // store.update_user_settings::( - // cx, - // |settings| { - // settings.defaults.remove_trailing_whitespace_on_save = Some(false); - // }, - // ); - // }); - // }); - - // // Stream edits again with trailing whitespace - // let edit_result = { - // let edit_task = cx.update(|cx| { - // let input = serde_json::to_value(EditFileToolInput { - // display_description: "Update main function".into(), - // path: "root/src/main.rs".into(), - // mode: EditFileMode::Overwrite, - // }) - // .unwrap(); - // Arc::new(EditFileTool) - // .run( - // input, - // Arc::default(), - // project.clone(), - // action_log.clone(), - // model.clone(), - // None, - // cx, - // ) - // .output - // }); - - // // Stream the content with trailing whitespace - // cx.executor().run_until_parked(); - // model.send_last_completion_stream_text_chunk( - // CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - // ); - // model.end_last_completion_stream(); - - // edit_task.await - // }; - // assert!(edit_result.is_ok()); - - // // Wait for any async operations (e.g. formatting) to complete - // cx.executor().run_until_parked(); - - // // Verify the file still has trailing whitespace - // // Read the file again - it should still have trailing whitespace - // let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - // assert_eq!( - // // Ignore carriage returns on Windows - // final_content.replace("\r\n", "\n"), - // CONTENT_WITH_TRAILING_WHITESPACE, - // "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - // ); - // } - - // #[gpui::test] - // async fn test_needs_confirmation(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree("/root", json!({})).await; - - // // Test 1: Path with .zed component should require confirmation - // let input_with_zed = json!({ - // "display_description": "Edit settings", - // "path": ".zed/settings.json", - // "mode": "edit" - // }); - // let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_with_zed, &project, cx), - // "Path with .zed component should require confirmation" - // ); - // }); - - // // Test 2: Absolute path should require confirmation - // let input_absolute = json!({ - // "display_description": "Edit file", - // "path": "/etc/hosts", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_absolute, &project, cx), - // "Absolute path should require confirmation" - // ); - // }); - - // // Test 3: Relative path without .zed should not require confirmation - // let input_relative = json!({ - // "display_description": "Edit file", - // "path": "root/src/main.rs", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // !tool.needs_confirmation(&input_relative, &project, cx), - // "Relative path without .zed should not require confirmation" - // ); - // }); - - // // Test 4: Path with .zed in the middle should require confirmation - // let input_zed_middle = json!({ - // "display_description": "Edit settings", - // "path": "root/.zed/tasks.json", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_zed_middle, &project, cx), - // "Path with .zed in any component should require confirmation" - // ); - // }); - - // // Test 5: When always_allow_tool_actions is enabled, no confirmation needed - // cx.update(|cx| { - // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - // settings.always_allow_tool_actions = true; - // agent_settings::AgentSettings::override_global(settings, cx); - - // assert!( - // !tool.needs_confirmation(&input_with_zed, &project, cx), - // "When always_allow_tool_actions is true, no confirmation should be needed" - // ); - // assert!( - // !tool.needs_confirmation(&input_absolute, &project, cx), - // "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" - // ); - // }); - // } - - // #[gpui::test] - // async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { - // // Set up a custom config directory for testing - // let temp_dir = tempfile::tempdir().unwrap(); - // init_test_with_config(cx, temp_dir.path()); - - // let tool = Arc::new(EditFileTool); - - // // Test ui_text shows context for various paths - // let test_cases = vec![ - // ( - // json!({ - // "display_description": "Update config", - // "path": ".zed/settings.json", - // "mode": "edit" - // }), - // "Update config (local settings)", - // ".zed path should show local settings context", - // ), - // ( - // json!({ - // "display_description": "Fix bug", - // "path": "src/.zed/local.json", - // "mode": "edit" - // }), - // "Fix bug (local settings)", - // "Nested .zed path should show local settings context", - // ), - // ( - // json!({ - // "display_description": "Update readme", - // "path": "README.md", - // "mode": "edit" - // }), - // "Update readme", - // "Normal path should not show additional context", - // ), - // ( - // json!({ - // "display_description": "Edit config", - // "path": "config.zed", - // "mode": "edit" - // }), - // "Edit config", - // ".zed as extension should not show context", - // ), - // ]; - - // for (input, expected_text, description) in test_cases { - // cx.update(|_cx| { - // let ui_text = tool.ui_text(&input); - // assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - // }); - // } - // } - - // #[gpui::test] - // async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - - // // Create a project in /project directory - // fs.insert_tree("/project", json!({})).await; - // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // // Test file outside project requires confirmation - // let input_outside = json!({ - // "display_description": "Edit file", - // "path": "/outside/file.txt", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_outside, &project, cx), - // "File outside project should require confirmation" - // ); - // }); - - // // Test file inside project doesn't require confirmation - // let input_inside = json!({ - // "display_description": "Edit file", - // "path": "project/file.txt", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // !tool.needs_confirmation(&input_inside, &project, cx), - // "File inside project should not require confirmation" - // ); - // }); - // } - - // #[gpui::test] - // async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { - // // Set up a custom data directory for testing - // let temp_dir = tempfile::tempdir().unwrap(); - // init_test_with_config(cx, temp_dir.path()); - - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree("/home/user/myproject", json!({})).await; - // let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - - // // Get the actual local settings folder name - // let local_settings_folder = paths::local_settings_folder_relative_path(); - - // // Test various config path patterns - // let test_cases = vec![ - // ( - // format!("{}/settings.json", local_settings_folder.display()), - // true, - // "Top-level local settings file".to_string(), - // ), - // ( - // format!( - // "myproject/{}/settings.json", - // local_settings_folder.display() - // ), - // true, - // "Local settings in project path".to_string(), - // ), - // ( - // format!("src/{}/config.toml", local_settings_folder.display()), - // true, - // "Local settings in subdirectory".to_string(), - // ), - // ( - // ".zed.backup/file.txt".to_string(), - // true, - // ".zed.backup is outside project".to_string(), - // ), - // ( - // "my.zed/file.txt".to_string(), - // true, - // "my.zed is outside project".to_string(), - // ), - // ( - // "myproject/src/file.zed".to_string(), - // false, - // ".zed as file extension".to_string(), - // ), - // ( - // "myproject/normal/path/file.rs".to_string(), - // false, - // "Normal file without config paths".to_string(), - // ), - // ]; - - // for (path, should_confirm, description) in test_cases { - // let input = json!({ - // "display_description": "Edit file", - // "path": path, - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert_eq!( - // tool.needs_confirmation(&input, &project, cx), - // should_confirm, - // "Failed for case: {} - path: {}", - // description, - // path - // ); - // }); - // } - // } - - // #[gpui::test] - // async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { - // // Set up a custom data directory for testing - // let temp_dir = tempfile::tempdir().unwrap(); - // init_test_with_config(cx, temp_dir.path()); - - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - - // // Create test files in the global config directory - // let global_config_dir = paths::config_dir(); - // fs::create_dir_all(&global_config_dir).unwrap(); - // let global_settings_path = global_config_dir.join("settings.json"); - // fs::write(&global_settings_path, "{}").unwrap(); - - // fs.insert_tree("/project", json!({})).await; - // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // // Test global config paths - // let test_cases = vec![ - // ( - // global_settings_path.to_str().unwrap().to_string(), - // true, - // "Global settings file should require confirmation", - // ), - // ( - // global_config_dir - // .join("keymap.json") - // .to_str() - // .unwrap() - // .to_string(), - // true, - // "Global keymap file should require confirmation", - // ), - // ( - // "project/normal_file.rs".to_string(), - // false, - // "Normal project file should not require confirmation", - // ), - // ]; - - // for (path, should_confirm, description) in test_cases { - // let input = json!({ - // "display_description": "Edit file", - // "path": path, - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert_eq!( - // tool.needs_confirmation(&input, &project, cx), - // should_confirm, - // "Failed for case: {}", - // description - // ); - // }); - // } - // } - - // #[gpui::test] - // async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - - // // Create multiple worktree directories - // fs.insert_tree( - // "/workspace/frontend", - // json!({ - // "src": { - // "main.js": "console.log('frontend');" - // } - // }), - // ) - // .await; - // fs.insert_tree( - // "/workspace/backend", - // json!({ - // "src": { - // "main.rs": "fn main() {}" - // } - // }), - // ) - // .await; - // fs.insert_tree( - // "/workspace/shared", - // json!({ - // ".zed": { - // "settings.json": "{}" - // } - // }), - // ) - // .await; - - // // Create project with multiple worktrees - // let project = Project::test( - // fs.clone(), - // [ - // path!("/workspace/frontend").as_ref(), - // path!("/workspace/backend").as_ref(), - // path!("/workspace/shared").as_ref(), - // ], - // cx, - // ) - // .await; - - // // Test files in different worktrees - // let test_cases = vec![ - // ("frontend/src/main.js", false, "File in first worktree"), - // ("backend/src/main.rs", false, "File in second worktree"), - // ( - // "shared/.zed/settings.json", - // true, - // ".zed file in third worktree", - // ), - // ("/etc/hosts", true, "Absolute path outside all worktrees"), - // ( - // "../outside/file.txt", - // true, - // "Relative path outside worktrees", - // ), - // ]; - - // for (path, should_confirm, description) in test_cases { - // let input = json!({ - // "display_description": "Edit file", - // "path": path, - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert_eq!( - // tool.needs_confirmation(&input, &project, cx), - // should_confirm, - // "Failed for case: {} - path: {}", - // description, - // path - // ); - // }); - // } - // } - - // #[gpui::test] - // async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree( - // "/project", - // json!({ - // ".zed": { - // "settings.json": "{}" - // }, - // "src": { - // ".zed": { - // "local.json": "{}" - // } - // } - // }), - // ) - // .await; - // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // // Test edge cases - // let test_cases = vec![ - // // Empty path - find_project_path returns Some for empty paths - // ("", false, "Empty path is treated as project root"), - // // Root directory - // ("/", true, "Root directory should be outside project"), - // // Parent directory references - find_project_path resolves these - // ( - // "project/../other", - // false, - // "Path with .. is resolved by find_project_path", - // ), - // ( - // "project/./src/file.rs", - // false, - // "Path with . should work normally", - // ), - // // Windows-style paths (if on Windows) - // #[cfg(target_os = "windows")] - // ("C:\\Windows\\System32\\hosts", true, "Windows system path"), - // #[cfg(target_os = "windows")] - // ("project\\src\\main.rs", false, "Windows-style project path"), - // ]; - - // for (path, should_confirm, description) in test_cases { - // let input = json!({ - // "display_description": "Edit file", - // "path": path, - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert_eq!( - // tool.needs_confirmation(&input, &project, cx), - // should_confirm, - // "Failed for case: {} - path: {}", - // description, - // path - // ); - // }); - // } - // } - - // #[gpui::test] - // async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - - // // Test UI text for various scenarios - // let test_cases = vec![ - // ( - // json!({ - // "display_description": "Update config", - // "path": ".zed/settings.json", - // "mode": "edit" - // }), - // "Update config (local settings)", - // ".zed path should show local settings context", - // ), - // ( - // json!({ - // "display_description": "Fix bug", - // "path": "src/.zed/local.json", - // "mode": "edit" - // }), - // "Fix bug (local settings)", - // "Nested .zed path should show local settings context", - // ), - // ( - // json!({ - // "display_description": "Update readme", - // "path": "README.md", - // "mode": "edit" - // }), - // "Update readme", - // "Normal path should not show additional context", - // ), - // ( - // json!({ - // "display_description": "Edit config", - // "path": "config.zed", - // "mode": "edit" - // }), - // "Edit config", - // ".zed as extension should not show context", - // ), - // ]; - - // for (input, expected_text, description) in test_cases { - // cx.update(|_cx| { - // let ui_text = tool.ui_text(&input); - // assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - // }); - // } - // } - - // #[gpui::test] - // async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { - // init_test(cx); - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree( - // "/project", - // json!({ - // "existing.txt": "content", - // ".zed": { - // "settings.json": "{}" - // } - // }), - // ) - // .await; - // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // // Test different EditFileMode values - // let modes = vec![ - // EditFileMode::Edit, - // EditFileMode::Create, - // EditFileMode::Overwrite, - // ]; - - // for mode in modes { - // // Test .zed path with different modes - // let input_zed = json!({ - // "display_description": "Edit settings", - // "path": "project/.zed/settings.json", - // "mode": mode - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_zed, &project, cx), - // ".zed path should require confirmation regardless of mode: {:?}", - // mode - // ); - // }); - - // // Test outside path with different modes - // let input_outside = json!({ - // "display_description": "Edit file", - // "path": "/outside/file.txt", - // "mode": mode - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input_outside, &project, cx), - // "Outside path should require confirmation regardless of mode: {:?}", - // mode - // ); - // }); - - // // Test normal path with different modes - // let input_normal = json!({ - // "display_description": "Edit file", - // "path": "project/normal.txt", - // "mode": mode - // }); - // cx.update(|cx| { - // assert!( - // !tool.needs_confirmation(&input_normal, &project, cx), - // "Normal path should not require confirmation regardless of mode: {:?}", - // mode - // ); - // }); - // } - // } - - // #[gpui::test] - // async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { - // // Set up with custom directories for deterministic testing - // let temp_dir = tempfile::tempdir().unwrap(); - // init_test_with_config(cx, temp_dir.path()); - - // let tool = Arc::new(EditFileTool); - // let fs = project::FakeFs::new(cx.executor()); - // fs.insert_tree("/project", json!({})).await; - // let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // // Enable always_allow_tool_actions - // cx.update(|cx| { - // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - // settings.always_allow_tool_actions = true; - // agent_settings::AgentSettings::override_global(settings, cx); - // }); - - // // Test that all paths that normally require confirmation are bypassed - // let global_settings_path = paths::config_dir().join("settings.json"); - // fs::create_dir_all(paths::config_dir()).unwrap(); - // fs::write(&global_settings_path, "{}").unwrap(); - - // let test_cases = vec![ - // ".zed/settings.json", - // "project/.zed/config.toml", - // global_settings_path.to_str().unwrap(), - // "/etc/hosts", - // "/absolute/path/file.txt", - // "../outside/project.txt", - // ]; - - // for path in test_cases { - // let input = json!({ - // "display_description": "Edit file", - // "path": path, - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // !tool.needs_confirmation(&input, &project, cx), - // "Path {} should not require confirmation when always_allow_tool_actions is true", - // path - // ); - // }); - // } - - // // Disable always_allow_tool_actions and verify confirmation is required again - // cx.update(|cx| { - // let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - // settings.always_allow_tool_actions = false; - // agent_settings::AgentSettings::override_global(settings, cx); - // }); - - // // Verify .zed path requires confirmation again - // let input = json!({ - // "display_description": "Edit file", - // "path": ".zed/settings.json", - // "mode": "edit" - // }); - // cx.update(|cx| { - // assert!( - // tool.needs_confirmation(&input, &project, cx), - // ".zed path should require confirmation when always_allow_tool_actions is false" - // ); - // }); - // } + #[gpui::test] + async fn test_format_on_save(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Set up a Rust language with LSP formatting support + let rust_language = Arc::new(language::Language::new( + language::LanguageConfig { + name: "Rust".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + )); + + // Register the language and fake LSP + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_language); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + // Create the file + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Open the buffer to trigger LSP initialization + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + // Register the buffer with language servers + let _handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; + const FORMATTED_CONTENT: &str = + "This file was formatted by the fake formatter in the test.\n"; + + // Get the fake language server and set up formatting handler + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::({ + |_, _| async move { + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), + new_text: FORMATTED_CONTENT.to_string(), + }])) + } + }); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + + // First, test with format_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.format_on_save = Some(FormatOnSave::On); + settings.defaults.formatter = + Some(language::language_settings::SelectedFormatter::Auto); + }, + ); + }); + }); + + // Have the model stream unformatted content + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the unformatted content + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Read the file to verify it was formatted automatically + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + new_content.replace("\r\n", "\n"), + FORMATTED_CONTENT, + "Code should be formatted when format_on_save is enabled" + ); + + let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); + + assert_eq!( + stale_buffer_count, 0, + "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ + This causes the agent to think the file was modified externally when it was just formatted.", + stale_buffer_count + ); + + // Next, test with format_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Off); + }, + ); + }); + }); + + // Stream unformatted edits again + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the unformatted content + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Verify the file was not formatted + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + new_content.replace("\r\n", "\n"), + UNFORMATTED_CONTENT, + "Code should not be formatted when format_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + // Create a simple file with trailing whitespace + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + + // First, test with remove_trailing_whitespace_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.remove_trailing_whitespace_on_save = Some(true); + }, + ); + }); + }); + + const CONTENT_WITH_TRAILING_WHITESPACE: &str = + "fn main() { \n println!(\"Hello!\"); \n}\n"; + + // Have the model stream content that contains trailing whitespace + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the content with trailing whitespace + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Read the file to verify trailing whitespace was removed automatically + assert_eq!( + // Ignore carriage returns on Windows + fs.load(path!("/root/src/main.rs").as_ref()) + .await + .unwrap() + .replace("\r\n", "\n"), + "fn main() {\n println!(\"Hello!\");\n}\n", + "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" + ); + + // Next, test with remove_trailing_whitespace_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::( + cx, + |settings| { + settings.defaults.remove_trailing_whitespace_on_save = Some(false); + }, + ); + }); + }); + + // Stream edits again with trailing whitespace + let edit_result = { + let edit_task = cx.update(|cx| { + let input = EditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Overwrite, + }; + Arc::new(EditFileTool { + thread: thread.clone(), + }) + .run(input, ToolCallEventStream::test().0, cx) + }); + + // Stream the content with trailing whitespace + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + CONTENT_WITH_TRAILING_WHITESPACE.to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!(edit_result.is_ok()); + + // Wait for any async operations (e.g. formatting) to complete + cx.executor().run_until_parked(); + + // Verify the file still has trailing whitespace + // Read the file again - it should still have trailing whitespace + let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + // Ignore carriage returns on Windows + final_content.replace("\r\n", "\n"), + CONTENT_WITH_TRAILING_WHITESPACE, + "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_needs_confirmation(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + fs.insert_tree("/root", json!({})).await; + + // Test 1: Path with .zed component should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 1".into(), + path: ".zed/settings.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 1 (local settings)"); + + // Test 2: Path outside project should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 2".into(), + path: "/etc/hosts".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 2"); + + // Test 3: Relative path without .zed should not require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 3".into(), + path: "root/src/main.rs".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + // Test 4: Path with .zed in the middle should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 4".into(), + path: "root/.zed/tasks.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 4 (local settings)"); + + // Test 5: Path outside of the project should require confirmation. + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 5".into(), + path: paths::config_dir().join("tasks.json"), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + let event = stream_rx.expect_tool_authorization().await; + assert_eq!(event.tool_call.title, "test 5 (global settings)"); + + // Test 6: When always_allow_tool_actions is enabled, no confirmation needed + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 6.1".into(), + path: ".zed/settings.json".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "test 6.2".into(), + path: "/etc/hosts".into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + } + + #[gpui::test] + async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + + // Create multiple worktree directories + fs.insert_tree( + "/workspace/frontend", + json!({ + "src": { + "main.js": "console.log('frontend');" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/backend", + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/shared", + json!({ + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + + // Create project with multiple worktrees + let project = Project::test( + fs.clone(), + [ + path!("/workspace/frontend").as_ref(), + path!("/workspace/backend").as_ref(), + path!("/workspace/shared").as_ref(), + ], + cx, + ) + .await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test files in different worktrees + let test_cases = vec![ + ("frontend/src/main.js", false, "File in first worktree"), + ("backend/src/main.rs", false, "File in second worktree"), + ( + "shared/.zed/settings.json", + true, + ".zed file in third worktree", + ), + ("/etc/hosts", true, "Absolute path outside all worktrees"), + ( + "../outside/file.txt", + true, + "Relative path outside worktrees", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".zed": { + "settings.json": "{}" + }, + "src": { + ".zed": { + "local.json": "{}" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test edge cases + let test_cases = vec![ + // Empty path - find_project_path returns Some for empty paths + ("", false, "Empty path is treated as project root"), + // Root directory + ("/", true, "Root directory should be outside project"), + // Parent directory references - find_project_path resolves these + ( + "project/../other", + false, + "Path with .. is resolved by find_project_path", + ), + ( + "project/./src/file.rs", + false, + "Path with . should work normally", + ), + // Windows-style paths (if on Windows) + #[cfg(target_os = "windows")] + ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + #[cfg(target_os = "windows")] + ("project\\src\\main.rs", false, "Windows-style project path"), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "existing.txt": "content", + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project.clone(), + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test different EditFileMode values + let modes = vec![ + EditFileMode::Edit, + EditFileMode::Create, + EditFileMode::Overwrite, + ]; + + for mode in modes { + // Test .zed path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit settings".into(), + path: "project/.zed/settings.json".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }); + + stream_rx.expect_tool_authorization().await; + + // Test outside path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: "/outside/file.txt".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }); + + stream_rx.expect_tool_authorization().await; + + // Test normal path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: "project/normal.txt".into(), + mode: mode.clone(), + }, + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + } + } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1552,18 +1316,4 @@ mod tests { Project::init_settings(cx); }); } - - fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { - cx.update(|cx| { - // Set custom data directory (config will be under data_dir/config) - paths::set_custom_data_dir(data_dir.to_str().unwrap()); - - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } } diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index 715d106a26..dcb14a48f3 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize}; use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::Poll}; use streaming_diff::{CharOperation, StreamingDiff}; use streaming_fuzzy_matcher::StreamingFuzzyMatcher; -use util::debug_panic; #[derive(Serialize)] struct CreateFilePromptTemplate { @@ -682,11 +681,6 @@ impl EditAgent { if last_message.content.is_empty() { conversation.messages.pop(); } - } else { - debug_panic!( - "Last message must be an Assistant tool calling! Got {:?}", - last_message.content - ); } } From 4d5b22a583c59503361031b17f6372339eda1efc Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 8 Aug 2025 14:03:34 +0200 Subject: [PATCH 20/22] Cleanup Co-authored-by: Antonio Scandurra --- crates/agent2/src/tools/edit_file_tool.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 2cd4cd5a96..fdb057c683 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -214,7 +214,7 @@ impl AgentTool for EditFileTool { model, project.clone(), action_log.clone(), - // todo! move edit agent to this crate so we can use our templates? + // TODO: move edit agent to this crate so we can use our templates assistant_tools::templates::Templates::new(), edit_format, ); @@ -226,14 +226,6 @@ impl AgentTool for EditFileTool { .await?; let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; - event_stream.send_update(acp::ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: project_path.path.to_path_buf(), - // todo! - line: None - }]), - ..Default::default() - }); event_stream.send_diff(diff.clone()); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; From ebc7df2c2e3257f12402dab57b7574080b57f3d6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 8 Aug 2025 14:28:03 +0200 Subject: [PATCH 21/22] Support images in read file tool Co-authored-by: Antonio Scandurra Co-authored-by: Agus Zubiaga --- crates/agent2/src/tests/mod.rs | 2 +- crates/agent2/src/thread.rs | 20 ++++- crates/agent2/src/tools/edit_file_tool.rs | 90 ++++++++++++++----- crates/agent2/src/tools/read_file_tool.rs | 105 +++++++++------------- crates/language_model/src/request.rs | 6 ++ 5 files changed, 137 insertions(+), 86 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 26cf38741b..b70f54ac0a 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -272,7 +272,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { tool_name: ToolRequiringPermission.name().into(), is_error: false, content: "Allowed".into(), - output: None + output: Some("Allowed".into()) }), MessageContent::ToolResult(LanguageModelToolResult { tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index e60387bd44..98f2d0651d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -510,15 +510,27 @@ impl Thread { status: Some(acp::ToolCallStatus::InProgress), ..Default::default() }); + let supports_images = self.selected_model.supports_images(); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); Some(cx.foreground_executor().spawn(async move { - match tool_result.await { - Ok(tool_output) => LanguageModelToolResult { + let tool_result = tool_result.await.and_then(|output| { + if let LanguageModelToolResultContent::Image(_) = &output.llm_output { + if !supports_images { + return Err(anyhow!( + "Attempted to read an image, but this model doesn't support it.", + )); + } + } + Ok(output) + }); + + match tool_result { + Ok(output) => LanguageModelToolResult { tool_use_id: tool_use.id, tool_name: tool_use.name, is_error: false, - content: tool_output.llm_output, - output: Some(tool_output.raw_output), + content: output.llm_output, + output: Some(output.raw_output), }, Err(error) => LanguageModelToolResult { tool_use_id: tool_use.id, diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index fdb057c683..0dbe0be217 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -882,7 +882,7 @@ mod tests { } #[gpui::test] - async fn test_needs_confirmation(cx: &mut TestAppContext) { + async fn test_authorize(cx: &mut TestAppContext) { init_test(cx); let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; @@ -967,23 +967,7 @@ mod tests { let event = stream_rx.expect_tool_authorization().await; assert_eq!(event.tool_call.title, "test 4 (local settings)"); - // Test 5: Path outside of the project should require confirmation. - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| { - tool.authorize( - &EditFileToolInput { - display_description: "test 5".into(), - path: paths::config_dir().join("tasks.json"), - mode: EditFileMode::Edit, - }, - &stream_tx, - cx, - ) - }); - let event = stream_rx.expect_tool_authorization().await; - assert_eq!(event.tool_call.title, "test 5 (global settings)"); - - // Test 6: When always_allow_tool_actions is enabled, no confirmation needed + // Test 5: When always_allow_tool_actions is enabled, no confirmation needed cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); settings.always_allow_tool_actions = true; @@ -994,7 +978,7 @@ mod tests { cx.update(|cx| { tool.authorize( &EditFileToolInput { - display_description: "test 6.1".into(), + display_description: "test 5.1".into(), path: ".zed/settings.json".into(), mode: EditFileMode::Edit, }, @@ -1010,7 +994,7 @@ mod tests { cx.update(|cx| { tool.authorize( &EditFileToolInput { - display_description: "test 6.2".into(), + display_description: "test 5.2".into(), path: "/etc/hosts".into(), mode: EditFileMode::Edit, }, @@ -1023,6 +1007,72 @@ mod tests { assert!(stream_rx.try_next().is_err()); } + #[gpui::test] + async fn test_authorize_global_config(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|_| { + Thread::new( + project, + Rc::default(), + action_log.clone(), + Templates::new(), + model.clone(), + ) + }); + let tool = Arc::new(EditFileTool { thread }); + + // Test global config paths - these should require confirmation if they exist and are outside the project + let test_cases = vec![ + ( + "/etc/hosts", + true, + "System file should require confirmation", + ), + ( + "/usr/local/bin/script", + true, + "System bin file should require confirmation", + ), + ( + "project/normal_file.rs", + false, + "Normal project file should not require confirmation", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = cx.update(|cx| { + tool.authorize( + &EditFileToolInput { + display_description: "Edit file".into(), + path: path.into(), + mode: EditFileMode::Edit, + }, + &stream_tx, + cx, + ) + }); + + if should_confirm { + stream_rx.expect_tool_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + #[gpui::test] async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index f85efed286..3d91e3dc74 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -1,10 +1,11 @@ use agent_client_protocol::{self as acp}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use assistant_tool::{outline, ActionLog}; use gpui::{Entity, Task}; use indoc::formatdoc; use language::{Anchor, Point}; -use project::{AgentLocation, Project, WorktreeSettings}; +use language_model::{LanguageModelImage, LanguageModelToolResultContent}; +use project::{image_store, AgentLocation, ImageItem, Project, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -59,7 +60,7 @@ impl ReadFileTool { impl AgentTool for ReadFileTool { type Input = ReadFileToolInput; - type Output = String; + type Output = LanguageModelToolResultContent; fn name(&self) -> SharedString { "read_file".into() @@ -92,9 +93,9 @@ impl AgentTool for ReadFileTool { fn run( self: Arc, input: Self::Input, - event_stream: ToolCallEventStream, + _event_stream: ToolCallEventStream, cx: &mut App, - ) -> Task> { + ) -> Task> { let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))); }; @@ -133,51 +134,27 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); - event_stream.send_update(acp::ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: project_path.path.to_path_buf(), - line: input.start_line, - // TODO (tracked): use full range - }]), - ..Default::default() - }); + if image_store::is_image_file(&self.project, &project_path, cx) { + return cx.spawn(async move |cx| { + let image_entity: Entity = cx + .update(|cx| { + self.project.update(cx, |project, cx| { + project.open_image(project_path.clone(), cx) + }) + })? + .await?; - // TODO (tracked): images - // if image_store::is_image_file(&self.project, &project_path, cx) { - // let model = &self.thread.read(cx).selected_model; + let image = + image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - // if !model.supports_images() { - // return Task::ready(Err(anyhow!( - // "Attempted to read an image, but Zed doesn't currently support sending images to {}.", - // model.name().0 - // ))) - // .into(); - // } + let language_model_image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await + .context("processing image")?; - // return cx.spawn(async move |cx| -> Result { - // let image_entity: Entity = cx - // .update(|cx| { - // self.project.update(cx, |project, cx| { - // project.open_image(project_path.clone(), cx) - // }) - // })? - // .await?; - - // let image = - // image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - - // let language_model_image = cx - // .update(|cx| LanguageModelImage::from_image(image, cx))? - // .await - // .context("processing image")?; - - // Ok(ToolResultOutput { - // content: ToolResultContent::Image(language_model_image), - // output: None, - // }) - // }); - // } - // + Ok(language_model_image.into()) + }); + } let project = self.project.clone(); let action_log = self.action_log.clone(); @@ -245,7 +222,7 @@ impl AgentTool for ReadFileTool { })?; } - Ok(result) + Ok(result.into()) } else { // No line ranges specified, so check file size to see if it's too big. let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; @@ -258,7 +235,7 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer, cx); })?; - Ok(result) + Ok(result.into()) } else { // File is too big, so return the outline // and a suggestion to read again with line numbers. @@ -277,7 +254,8 @@ impl AgentTool for ReadFileTool { Alternatively, you can fall back to the `grep` tool (if available) to search the file for specific content." - }) + } + .into()) } } }) @@ -346,7 +324,7 @@ mod test { tool.run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "This is a small file content"); + assert_eq!(result.unwrap(), "This is a small file content".into()); } #[gpui::test] @@ -366,7 +344,7 @@ mod test { language_registry.add(Arc::new(rust_lang())); let action_log = cx.new(|_| ActionLog::new(project.clone())); let tool = Arc::new(ReadFileTool::new(project, action_log)); - let content = cx + let result = cx .update(|cx| { let input = ReadFileToolInput { path: "root/large_file.rs".into(), @@ -377,6 +355,7 @@ mod test { }) .await .unwrap(); + let content = result.to_str().unwrap(); assert_eq!( content.lines().skip(4).take(6).collect::>(), @@ -399,8 +378,9 @@ mod test { }; tool.run(input, ToolCallEventStream::test().0, cx) }) - .await; - let content = result.unwrap(); + .await + .unwrap(); + let content = result.to_str().unwrap(); let expected_content = (0..1000) .flat_map(|i| { vec![ @@ -446,7 +426,7 @@ mod test { tool.run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4"); + assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into()); } #[gpui::test] @@ -476,7 +456,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1\nLine 2"); + assert_eq!(result.unwrap(), "Line 1\nLine 2".into()); // end_line of 0 should result in at least 1 line let result = cx @@ -489,7 +469,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1"); + assert_eq!(result.unwrap(), "Line 1".into()); // when start_line > end_line, should still return at least 1 line let result = cx @@ -502,7 +482,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 3"); + assert_eq!(result.unwrap(), "Line 3".into()); } fn init_test(cx: &mut TestAppContext) { @@ -730,7 +710,7 @@ mod test { }) .await; assert!(result.is_ok(), "Should be able to read normal files"); - assert_eq!(result.unwrap(), "Normal file content"); + assert_eq!(result.unwrap(), "Normal file content".into()); // Path traversal attempts with .. should fail let result = cx @@ -835,7 +815,10 @@ mod test { .await .unwrap(); - assert_eq!(result, "fn main() { println!(\"Hello from worktree1\"); }"); + assert_eq!( + result, + "fn main() { println!(\"Hello from worktree1\"); }".into() + ); // Test reading private file in worktree1 should fail let result = cx @@ -894,7 +877,7 @@ mod test { assert_eq!( result, - "export function greet() { return 'Hello from worktree2'; }" + "export function greet() { return 'Hello from worktree2'; }".into() ); // Test reading private file in worktree2 should fail diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index dc485e9937..edce3d03b7 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -297,6 +297,12 @@ impl From for LanguageModelToolResultContent { } } +impl From for LanguageModelToolResultContent { + fn from(image: LanguageModelImage) -> Self { + Self::Image(image) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] pub enum MessageContent { Text(String), From 320312fa255deeb31966d4f684652e20e94b2edd Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 8 Aug 2025 14:57:38 +0200 Subject: [PATCH 22/22] Handle streaming input in title updates Co-authored-by: Antonio Scandurra --- crates/agent2/src/tests/mod.rs | 32 +++++++++++++++++++++++++++++--- crates/agent2/src/thread.rs | 29 ++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index b70f54ac0a..cd1249849e 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -647,6 +647,19 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let mut events = thread.update(cx, |thread, cx| thread.send(model.clone(), "Think", cx)); cx.run_until_parked(); + // Simulate streaming partial input. + let input = json!({}); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "1".into(), + name: ThinkingTool.name().into(), + raw_input: input.to_string(), + input, + is_input_complete: false, + }, + )); + + // Input streaming completed let input = json!({ "content": "Thinking hard!" }); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -665,12 +678,12 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { tool_call, acp::ToolCall { id: acp::ToolCallId("1".into()), - title: "Thinking".into(), + title: "thinking".into(), kind: acp::ToolKind::Think, status: acp::ToolCallStatus::Pending, content: vec![], locations: vec![], - raw_input: Some(json!({ "content": "Thinking hard!" })), + raw_input: Some(json!({})), raw_output: None, } ); @@ -680,7 +693,20 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { acp::ToolCallUpdate { id: acp::ToolCallId("1".into()), fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress,), + title: Some("Thinking".into()), + kind: Some(acp::ToolKind::Think), + raw_input: Some(json!({ "content": "Thinking hard!" })), + ..Default::default() + }, + } + ); + let update = expect_tool_call_update(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), ..Default::default() }, } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 98f2d0651d..78d4fb1f0d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -474,8 +474,17 @@ impl Thread { } }); + let mut title = SharedString::from(&tool_use.name); + let mut kind = acp::ToolKind::Other; + if let Some(tool) = tool.as_ref() { + if let Ok(initial_title) = tool.initial_title(tool_use.input.clone()) { + title = initial_title; + } + kind = tool.kind(); + } + if push_new_tool_use { - event_stream.send_tool_call(tool.as_ref(), &tool_use); + event_stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone()); last_message .content .push(MessageContent::ToolUse(tool_use.clone())); @@ -483,6 +492,8 @@ impl Thread { event_stream.send_tool_call_update( &tool_use.id, acp::ToolCallUpdateFields { + title: Some(title.into()), + kind: Some(kind), raw_input: Some(tool_use.input.clone()), ..Default::default() }, @@ -842,17 +853,17 @@ impl AgentResponseEventStream { fn send_tool_call( &self, - tool: Option<&Arc>, - tool_use: &LanguageModelToolUse, + id: &LanguageModelToolUseId, + title: SharedString, + kind: acp::ToolKind, + input: serde_json::Value, ) { self.0 .unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call( - &tool_use.id, - tool.and_then(|t| t.initial_title(tool_use.input.clone()).ok()) - .map(|i| i.into()) - .unwrap_or_else(|| tool_use.name.to_string()), - tool.map(|t| t.kind()).unwrap_or(acp::ToolKind::Other), - tool_use.input.clone(), + id, + title.to_string(), + kind, + input, )))) .ok(); }