Add save_file and restore_file_from_disk agent tools (#44789)

Nathan Sobo created

Release Notes:

- Added `save_file` and `restore_file_from_disk` tools to the agent,
allowing it to resolve dirty buffer conflicts when editing files. When
the agent encounters a file with unsaved changes, it will now ask
whether you want to keep or discard those changes before proceeding.

Change summary

crates/agent/src/thread.rs                            |   5 
crates/agent/src/tools.rs                             |   8 
crates/agent/src/tools/edit_file_tool.rs              |  23 
crates/agent/src/tools/restore_file_from_disk_tool.rs | 352 +++++++++++++
crates/agent/src/tools/save_file_tool.rs              | 351 ++++++++++++
5 files changed, 732 insertions(+), 7 deletions(-)

Detailed changes

crates/agent/src/thread.rs 🔗

@@ -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);

crates/agent/src/tools.rs 🔗

@@ -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,

crates/agent/src/tools/edit_file_tool.rs 🔗

@@ -316,9 +316,9 @@ impl AgentTool for EditFileTool {
                 // 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."
+                        "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."
                     );
                 }
 
@@ -2202,9 +2202,24 @@ 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
+        );
+        assert!(
+            error_msg.contains("save_file"),
+            "Error should reference save_file tool, got: {}",
+            error_msg
+        );
+        assert!(
+            error_msg.contains("restore_file_from_disk"),
+            "Error should reference restore_file_from_disk tool, got: {}",
+            error_msg
+        );
     }
 }

crates/agent/src/tools/restore_file_from_disk_tool.rs 🔗

@@ -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 &not_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
+    }
+}

crates/agent/src/tools/save_file_tool.rs 🔗

@@ -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 &not_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}"
+        );
+    }
+}