From 9c32c29238d2f4b6006e61b979079482bf07f9dd Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 16 Dec 2025 01:53:08 -0700 Subject: [PATCH] Revert "Add save_file and restore_file_from_disk agent tools" (#44949) Reverts zed-industries/zed#44789 Need to fix a bug Release Notes: - N/A --- crates/agent/src/thread.rs | 5 +- crates/agent/src/tools.rs | 8 +- crates/agent/src/tools/edit_file_tool.rs | 23 +- .../src/tools/restore_file_from_disk_tool.rs | 352 ------------------ crates/agent/src/tools/save_file_tool.rs | 351 ----------------- 5 files changed, 7 insertions(+), 732 deletions(-) delete mode 100644 crates/agent/src/tools/restore_file_from_disk_tool.rs delete mode 100644 crates/agent/src/tools/save_file_tool.rs diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 837bf454a2431c4a1efa81679adc6ed9ef355908..dbf29c68766cfe28d0bce1d82ed53536446326e2 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,8 +2,7 @@ use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool, - ThinkingTool, WebSearchTool, + SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -1003,8 +1002,6 @@ 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); diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 358903a32baa5ead9b073642015e6829501307a2..62a52998a705e11d1c9e69cbade7f427cc9cfc32 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -4,6 +4,7 @@ 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; @@ -12,8 +13,6 @@ 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; @@ -28,6 +27,7 @@ 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,8 +36,6 @@ 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::*; @@ -94,8 +92,6 @@ tools! { NowTool, OpenTool, ReadFileTool, - RestoreFileFromDiskTool, - SaveFileTool, TerminalTool, ThinkingTool, WebSearchTool, diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index c08300e19541cad49033093f0d2bbe3a5b233683..0ab99426e2e9645adf3f837d21c28dc285ab6ea2 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/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 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." + "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." ); } @@ -2202,24 +2202,9 @@ 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("This file has unsaved changes."), + error_msg.contains("cannot be written to because it 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 - ); } } diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs deleted file mode 100644 index f5723f6ee3ee46144152dd3ed2939ab2cfaca9c0..0000000000000000000000000000000000000000 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ /dev/null @@ -1,352 +0,0 @@ -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, -} - -pub struct RestoreFileFromDiskTool { - project: Entity, -} - -impl RestoreFileFromDiskTool { - pub fn new(project: Entity) -> 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, - _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, - input: Self::Input, - _event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let project = self.project.clone(); - let input_paths = input.paths; - - cx.spawn(async move |cx| { - let mut buffers_to_reload: FxHashSet> = FxHashSet::default(); - - let mut restored_paths: Vec = Vec::new(); - let mut clean_paths: Vec = Vec::new(); - let mut not_found_paths: Vec = 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 = 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 = 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 - } -} diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs deleted file mode 100644 index 429352200109c52303c9f6f94a28a49136af1a61..0000000000000000000000000000000000000000 --- a/crates/agent/src/tools/save_file_tool.rs +++ /dev/null @@ -1,351 +0,0 @@ -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, -} - -pub struct SaveFileTool { - project: Entity, -} - -impl SaveFileTool { - pub fn new(project: Entity) -> 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, - _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, - input: Self::Input, - _event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let project = self.project.clone(); - let input_paths = input.paths; - - cx.spawn(async move |cx| { - let mut buffers_to_save: FxHashSet> = FxHashSet::default(); - - let mut saved_paths: Vec = Vec::new(); - let mut clean_paths: Vec = Vec::new(); - let mut not_found_paths: Vec = 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(|| "".to_string()), - Err(error) => { - save_errors.push(("".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 = 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}" - ); - } -}