Detailed changes
@@ -972,6 +972,8 @@
"now": true,
"find_path": true,
"read_file": true,
+ "restore_file_from_disk": true,
+ "save_file": true,
"open": true,
"grep": true,
"terminal": true,
@@ -2,7 +2,8 @@ use crate::{
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
- SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
+ RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
+ ThinkingTool, WebSearchTool,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -1002,6 +1003,8 @@ impl Thread {
self.project.clone(),
self.action_log.clone(),
));
+ self.add_tool(SaveFileTool::new(self.project.clone()));
+ self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
@@ -1966,6 +1969,12 @@ impl Thread {
self.running_turn.as_ref()?.tools.get(name).cloned()
}
+ pub fn has_tool(&self, name: &str) -> bool {
+ self.running_turn
+ .as_ref()
+ .is_some_and(|turn| turn.tools.contains_key(name))
+ }
+
fn build_request_messages(
&self,
available_tools: Vec<SharedString>,
@@ -4,7 +4,6 @@ mod create_directory_tool;
mod delete_path_tool;
mod diagnostics_tool;
mod edit_file_tool;
-
mod fetch_tool;
mod find_path_tool;
mod grep_tool;
@@ -13,6 +12,8 @@ mod move_path_tool;
mod now_tool;
mod open_tool;
mod read_file_tool;
+mod restore_file_from_disk_tool;
+mod save_file_tool;
mod terminal_tool;
mod thinking_tool;
@@ -27,7 +28,6 @@ pub use create_directory_tool::*;
pub use delete_path_tool::*;
pub use diagnostics_tool::*;
pub use edit_file_tool::*;
-
pub use fetch_tool::*;
pub use find_path_tool::*;
pub use grep_tool::*;
@@ -36,6 +36,8 @@ pub use move_path_tool::*;
pub use now_tool::*;
pub use open_tool::*;
pub use read_file_tool::*;
+pub use restore_file_from_disk_tool::*;
+pub use save_file_tool::*;
pub use terminal_tool::*;
pub use thinking_tool::*;
@@ -92,6 +94,8 @@ tools! {
NowTool,
OpenTool,
ReadFileTool,
+ RestoreFileFromDiskTool,
+ SaveFileTool,
TerminalTool,
ThinkingTool,
WebSearchTool,
@@ -306,20 +306,39 @@ impl AgentTool for EditFileTool {
// Check if the file has been modified since the agent last read it
if let Some(abs_path) = abs_path.as_ref() {
- let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| {
+ let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
let last_read = thread.file_read_times.get(abs_path).copied();
let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
let dirty = buffer.read(cx).is_dirty();
- (last_read, current, dirty)
+ let has_save = thread.has_tool("save_file");
+ let has_restore = thread.has_tool("restore_file_from_disk");
+ (last_read, current, dirty, has_save, has_restore)
})?;
// Check for unsaved changes first - these indicate modifications we don't know about
if is_dirty {
- anyhow::bail!(
- "This file cannot be written to because it has unsaved changes. \
- Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \
- Ask the user to save that buffer's changes and to inform you when it's ok to proceed."
- );
+ let message = match (has_save_tool, has_restore_tool) {
+ (true, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (true, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
+ }
+ (false, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (false, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
+ then ask them to save or revert the file manually and inform you when it's ok to proceed."
+ }
+ };
+ anyhow::bail!("{}", message);
}
// Check if the file was modified on disk since we last read it
@@ -2202,9 +2221,21 @@ mod tests {
assert!(result.is_err(), "Edit should fail when buffer is dirty");
let error_msg = result.unwrap_err().to_string();
assert!(
- error_msg.contains("cannot be written to because it has unsaved changes"),
+ error_msg.contains("This file has unsaved changes."),
"Error should mention unsaved changes, got: {}",
error_msg
);
+ assert!(
+ error_msg.contains("keep or discard"),
+ "Error should ask whether to keep or discard changes, got: {}",
+ error_msg
+ );
+ // Since save_file and restore_file_from_disk tools aren't added to the thread,
+ // the error message should ask the user to manually save or revert
+ assert!(
+ error_msg.contains("save or revert the file manually"),
+ "Error should ask user to manually save or revert when tools aren't available, got: {}",
+ error_msg
+ );
}
}
@@ -0,0 +1,352 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Discards unsaved changes in open buffers by reloading file contents from disk.
+///
+/// Use this tool when:
+/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
+/// - You want to reset files to the on-disk state before retrying an edit.
+///
+/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct RestoreFileFromDiskToolInput {
+ /// The paths of the files to restore from disk.
+ pub paths: Vec<PathBuf>,
+}
+
+pub struct RestoreFileFromDiskTool {
+ project: Entity<Project>,
+}
+
+impl RestoreFileFromDiskTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for RestoreFileFromDiskTool {
+ type Input = RestoreFileFromDiskToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "restore_file_from_disk"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ _cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
+ Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
+ Err(_) => "Restore files from disk".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ let project = self.project.clone();
+ let input_paths = input.paths;
+
+ cx.spawn(async move |cx| {
+ let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+ let mut restored_paths: Vec<PathBuf> = Vec::new();
+ let mut clean_paths: Vec<PathBuf> = Vec::new();
+ let mut not_found_paths: Vec<PathBuf> = Vec::new();
+ let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut reload_errors: Vec<String> = Vec::new();
+
+ for path in input_paths {
+ let project_path =
+ project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+ let project_path = match project_path {
+ Ok(Some(project_path)) => project_path,
+ Ok(None) => {
+ not_found_paths.push(path);
+ continue;
+ }
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let open_buffer_task =
+ project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+ let buffer = match open_buffer_task {
+ Ok(task) => match task.await {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ },
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+ Ok(is_dirty) => is_dirty,
+ Err(error) => {
+ dirty_check_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ if is_dirty {
+ buffers_to_reload.insert(buffer);
+ restored_paths.push(path);
+ } else {
+ clean_paths.push(path);
+ }
+ }
+
+ if !buffers_to_reload.is_empty() {
+ let reload_task = project.update(cx, |project, cx| {
+ project.reload_buffers(buffers_to_reload, true, cx)
+ });
+
+ match reload_task {
+ Ok(task) => {
+ if let Err(error) = task.await {
+ reload_errors.push(error.to_string());
+ }
+ }
+ Err(error) => {
+ reload_errors.push(error.to_string());
+ }
+ }
+ }
+
+ let mut lines: Vec<String> = Vec::new();
+
+ if !restored_paths.is_empty() {
+ lines.push(format!("Restored {} file(s).", restored_paths.len()));
+ }
+ if !clean_paths.is_empty() {
+ lines.push(format!("{} clean.", clean_paths.len()));
+ }
+
+ if !not_found_paths.is_empty() {
+ lines.push(format!("Not found ({}):", not_found_paths.len()));
+ for path in ¬_found_paths {
+ lines.push(format!("- {}", path.display()));
+ }
+ }
+ if !open_errors.is_empty() {
+ lines.push(format!("Open failed ({}):", open_errors.len()));
+ for (path, error) in &open_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !dirty_check_errors.is_empty() {
+ lines.push(format!(
+ "Dirty check failed ({}):",
+ dirty_check_errors.len()
+ ));
+ for (path, error) in &dirty_check_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !reload_errors.is_empty() {
+ lines.push(format!("Reload failed ({}):", reload_errors.len()));
+ for error in &reload_errors {
+ lines.push(format!("- {}", error));
+ }
+ }
+
+ if lines.is_empty() {
+ Ok("No paths provided.".to_string())
+ } else {
+ Ok(lines.join("\n"))
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::Fs;
+ use gpui::TestAppContext;
+ use language::LineEnding;
+ use project::FakeFs;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dirty.txt": "on disk: dirty\n",
+ "clean.txt": "on disk: clean\n",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
+
+ // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
+ let dirty_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/dirty.txt", cx)
+ .expect("dirty.txt should exist in project")
+ });
+
+ let dirty_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(dirty_project_path, cx)
+ })
+ .await
+ .unwrap();
+ dirty_buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+ });
+ assert!(
+ dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should be dirty before restore"
+ );
+
+ // Ensure clean.txt is opened but remains clean.
+ let clean_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/clean.txt", cx)
+ .expect("clean.txt should exist in project")
+ });
+
+ let clean_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(clean_project_path, cx)
+ })
+ .await
+ .unwrap();
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should start clean"
+ );
+
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput {
+ paths: vec![
+ PathBuf::from("root/dirty.txt"),
+ PathBuf::from("root/clean.txt"),
+ ],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ // Output should mention restored + clean.
+ assert!(
+ output.contains("Restored 1 file(s)."),
+ "expected restored count line, got:\n{output}"
+ );
+ assert!(
+ output.contains("1 clean."),
+ "expected clean count line, got:\n{output}"
+ );
+
+ // Effect: dirty buffer should be restored back to disk content and become clean.
+ let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
+ assert_eq!(
+ dirty_text, "on disk: dirty\n",
+ "dirty.txt buffer should be restored to disk contents"
+ );
+ assert!(
+ !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should not be dirty after restore"
+ );
+
+ // Disk contents should be unchanged (restore-from-disk should not write).
+ let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+ assert_eq!(disk_dirty, "on disk: dirty\n");
+
+ // Sanity: clean buffer should remain clean and unchanged.
+ let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
+ assert_eq!(clean_text, "on disk: clean\n");
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should remain clean"
+ );
+
+ // Test empty paths case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput { paths: vec![] },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert_eq!(output, "No paths provided.");
+
+ // Test not-found path case (path outside the project root).
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ RestoreFileFromDiskToolInput {
+ paths: vec![PathBuf::from("nonexistent/path.txt")],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(
+ output.contains("Not found (1):"),
+ "expected not-found header line, got:\n{output}"
+ );
+ assert!(
+ output.contains("- nonexistent/path.txt"),
+ "expected not-found path bullet, got:\n{output}"
+ );
+
+ let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
+ }
+}
@@ -0,0 +1,351 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Saves files that have unsaved changes.
+///
+/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
+/// Only use this tool after asking the user for permission to save their unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct SaveFileToolInput {
+ /// The paths of the files to save.
+ pub paths: Vec<PathBuf>,
+}
+
+pub struct SaveFileTool {
+ project: Entity<Project>,
+}
+
+impl SaveFileTool {
+ pub fn new(project: Entity<Project>) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for SaveFileTool {
+ type Input = SaveFileToolInput;
+ type Output = String;
+
+ fn name() -> &'static str {
+ "save_file"
+ }
+
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Other
+ }
+
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ _cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) if input.paths.len() == 1 => "Save file".into(),
+ Ok(input) => format!("Save {} files", input.paths.len()).into(),
+ Err(_) => "Save files".into(),
+ }
+ }
+
+ fn run(
+ self: Arc<Self>,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<String>> {
+ let project = self.project.clone();
+ let input_paths = input.paths;
+
+ cx.spawn(async move |cx| {
+ let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+ let mut saved_paths: Vec<PathBuf> = Vec::new();
+ let mut clean_paths: Vec<PathBuf> = Vec::new();
+ let mut not_found_paths: Vec<PathBuf> = Vec::new();
+ let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+ let mut save_errors: Vec<(String, String)> = Vec::new();
+
+ for path in input_paths {
+ let project_path =
+ project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+ let project_path = match project_path {
+ Ok(Some(project_path)) => project_path,
+ Ok(None) => {
+ not_found_paths.push(path);
+ continue;
+ }
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let open_buffer_task =
+ project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+ let buffer = match open_buffer_task {
+ Ok(task) => match task.await {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ },
+ Err(error) => {
+ open_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+ Ok(is_dirty) => is_dirty,
+ Err(error) => {
+ dirty_check_errors.push((path, error.to_string()));
+ continue;
+ }
+ };
+
+ if is_dirty {
+ buffers_to_save.insert(buffer);
+ saved_paths.push(path);
+ } else {
+ clean_paths.push(path);
+ }
+ }
+
+ // Save each buffer individually since there's no batch save API.
+ for buffer in buffers_to_save {
+ let path_for_buffer = match buffer.read_with(cx, |buffer, _| {
+ buffer
+ .file()
+ .map(|file| file.path().to_rel_path_buf())
+ .map(|path| path.as_rel_path().as_unix_str().to_owned())
+ }) {
+ Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
+ Err(error) => {
+ save_errors.push(("<unknown>".to_string(), error.to_string()));
+ continue;
+ }
+ };
+
+ let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
+
+ match save_task {
+ Ok(task) => {
+ if let Err(error) = task.await {
+ save_errors.push((path_for_buffer, error.to_string()));
+ }
+ }
+ Err(error) => {
+ save_errors.push((path_for_buffer, error.to_string()));
+ }
+ }
+ }
+
+ let mut lines: Vec<String> = Vec::new();
+
+ if !saved_paths.is_empty() {
+ lines.push(format!("Saved {} file(s).", saved_paths.len()));
+ }
+ if !clean_paths.is_empty() {
+ lines.push(format!("{} clean.", clean_paths.len()));
+ }
+
+ if !not_found_paths.is_empty() {
+ lines.push(format!("Not found ({}):", not_found_paths.len()));
+ for path in ¬_found_paths {
+ lines.push(format!("- {}", path.display()));
+ }
+ }
+ if !open_errors.is_empty() {
+ lines.push(format!("Open failed ({}):", open_errors.len()));
+ for (path, error) in &open_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !dirty_check_errors.is_empty() {
+ lines.push(format!(
+ "Dirty check failed ({}):",
+ dirty_check_errors.len()
+ ));
+ for (path, error) in &dirty_check_errors {
+ lines.push(format!("- {}: {}", path.display(), error));
+ }
+ }
+ if !save_errors.is_empty() {
+ lines.push(format!("Save failed ({}):", save_errors.len()));
+ for (path, error) in &save_errors {
+ lines.push(format!("- {}: {}", path, error));
+ }
+ }
+
+ if lines.is_empty() {
+ Ok("No paths provided.".to_string())
+ } else {
+ Ok(lines.join("\n"))
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::Fs;
+ use gpui::TestAppContext;
+ use project::FakeFs;
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dirty.txt": "on disk: dirty\n",
+ "clean.txt": "on disk: clean\n",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+ let tool = Arc::new(SaveFileTool::new(project.clone()));
+
+ // Make dirty.txt dirty in-memory.
+ let dirty_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/dirty.txt", cx)
+ .expect("dirty.txt should exist in project")
+ });
+
+ let dirty_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(dirty_project_path, cx)
+ })
+ .await
+ .unwrap();
+ dirty_buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+ });
+ assert!(
+ dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should be dirty before save"
+ );
+
+ // Ensure clean.txt is opened but remains clean.
+ let clean_project_path = project.read_with(cx, |project, cx| {
+ project
+ .find_project_path("root/clean.txt", cx)
+ .expect("clean.txt should exist in project")
+ });
+
+ let clean_buffer = project
+ .update(cx, |project, cx| {
+ project.open_buffer(clean_project_path, cx)
+ })
+ .await
+ .unwrap();
+ assert!(
+ !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "clean.txt buffer should start clean"
+ );
+
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput {
+ paths: vec![
+ PathBuf::from("root/dirty.txt"),
+ PathBuf::from("root/clean.txt"),
+ ],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ // Output should mention saved + clean.
+ assert!(
+ output.contains("Saved 1 file(s)."),
+ "expected saved count line, got:\n{output}"
+ );
+ assert!(
+ output.contains("1 clean."),
+ "expected clean count line, got:\n{output}"
+ );
+
+ // Effect: dirty buffer should now be clean and disk should have new content.
+ assert!(
+ !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+ "dirty.txt buffer should not be dirty after save"
+ );
+
+ let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+ assert_eq!(
+ disk_dirty, "in memory: dirty\n",
+ "dirty.txt disk content should be updated"
+ );
+
+ // Sanity: clean buffer should remain clean and disk unchanged.
+ let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
+ assert_eq!(disk_clean, "on disk: clean\n");
+
+ // Test empty paths case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput { paths: vec![] },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert_eq!(output, "No paths provided.");
+
+ // Test not-found path case.
+ let output = cx
+ .update(|cx| {
+ tool.clone().run(
+ SaveFileToolInput {
+ paths: vec![PathBuf::from("nonexistent/path.txt")],
+ },
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+ assert!(
+ output.contains("Not found (1):"),
+ "expected not-found header line, got:\n{output}"
+ );
+ assert!(
+ output.contains("- nonexistent/path.txt"),
+ "expected not-found path bullet, got:\n{output}"
+ );
+ }
+}