1use crate::{
2 AgentTool, Templates, Thread, ToolCallEventStream, ToolPermissionDecision,
3 decide_permission_from_settings,
4 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
5};
6use acp_thread::Diff;
7use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
8use anyhow::{Context as _, Result, anyhow};
9use cloud_llm_client::CompletionIntent;
10use collections::HashSet;
11use futures::{FutureExt as _, StreamExt as _};
12use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
13use indoc::formatdoc;
14use language::language_settings::{self, FormatOnSave};
15use language::{LanguageRegistry, ToPoint};
16use language_model::LanguageModelToolResultContent;
17use paths;
18use project::lsp_store::{FormatTrigger, LspFormatTarget};
19use project::{Project, ProjectPath};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use settings::Settings;
23use std::ffi::OsStr;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use ui::SharedString;
27use util::ResultExt;
28use util::rel_path::RelPath;
29
30const DEFAULT_UI_TEXT: &str = "Editing file";
31
32/// 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.
33///
34/// Before using this tool:
35///
36/// 1. Use the `read_file` tool to understand the file's contents and context
37///
38/// 2. Verify the directory path is correct (only applicable when creating new files):
39/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
40#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
41pub struct EditFileToolInput {
42 /// 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.
43 ///
44 /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
45 ///
46 /// NEVER mention the file path in this description.
47 ///
48 /// <example>Fix API endpoint URLs</example>
49 /// <example>Update copyright year in `page_footer`</example>
50 ///
51 /// Make sure to include this field before all the others in the input object so that we can display it immediately.
52 pub display_description: String,
53
54 /// The full path of the file to create or modify in the project.
55 ///
56 /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
57 ///
58 /// The following examples assume we have two root directories in the project:
59 /// - /a/b/backend
60 /// - /c/d/frontend
61 ///
62 /// <example>
63 /// `backend/src/main.rs`
64 ///
65 /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
66 /// </example>
67 ///
68 /// <example>
69 /// `frontend/db.js`
70 /// </example>
71 pub path: PathBuf,
72 /// The mode of operation on the file. Possible values:
73 /// - 'edit': Make granular edits to an existing file.
74 /// - 'create': Create a new file if it doesn't exist.
75 /// - 'overwrite': Replace the entire contents of an existing file.
76 ///
77 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
78 pub mode: EditFileMode,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
82struct EditFileToolPartialInput {
83 #[serde(default)]
84 path: String,
85 #[serde(default)]
86 display_description: String,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "lowercase")]
91#[schemars(inline)]
92pub enum EditFileMode {
93 Edit,
94 Create,
95 Overwrite,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
99pub struct EditFileToolOutput {
100 #[serde(alias = "original_path")]
101 input_path: PathBuf,
102 new_text: String,
103 old_text: Arc<String>,
104 #[serde(default)]
105 diff: String,
106 #[serde(alias = "raw_output")]
107 edit_agent_output: EditAgentOutput,
108}
109
110impl From<EditFileToolOutput> for LanguageModelToolResultContent {
111 fn from(output: EditFileToolOutput) -> Self {
112 if output.diff.is_empty() {
113 "No edits were made.".into()
114 } else {
115 format!(
116 "Edited {}:\n\n```diff\n{}\n```",
117 output.input_path.display(),
118 output.diff
119 )
120 .into()
121 }
122 }
123}
124
125pub struct EditFileTool {
126 thread: WeakEntity<Thread>,
127 language_registry: Arc<LanguageRegistry>,
128 project: Entity<Project>,
129 templates: Arc<Templates>,
130}
131
132impl EditFileTool {
133 pub fn new(
134 project: Entity<Project>,
135 thread: WeakEntity<Thread>,
136 language_registry: Arc<LanguageRegistry>,
137 templates: Arc<Templates>,
138 ) -> Self {
139 Self {
140 project,
141 thread,
142 language_registry,
143 templates,
144 }
145 }
146
147 pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
148 Self {
149 project: self.project.clone(),
150 thread: new_thread,
151 language_registry: self.language_registry.clone(),
152 templates: self.templates.clone(),
153 }
154 }
155
156 fn authorize(
157 &self,
158 input: &EditFileToolInput,
159 event_stream: &ToolCallEventStream,
160 cx: &mut App,
161 ) -> Task<Result<()>> {
162 let path_str = input.path.to_string_lossy();
163 let settings = agent_settings::AgentSettings::get_global(cx);
164 let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
165
166 match decision {
167 ToolPermissionDecision::Allow => return Task::ready(Ok(())),
168 ToolPermissionDecision::Deny(reason) => {
169 return Task::ready(Err(anyhow!("{}", reason)));
170 }
171 ToolPermissionDecision::Confirm => {}
172 }
173
174 // If any path component matches the local settings folder, then this could affect
175 // the editor in ways beyond the project source, so prompt.
176 let local_settings_folder = paths::local_settings_folder_name();
177 let path = Path::new(&input.path);
178 if path.components().any(|component| {
179 component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
180 }) {
181 let context = crate::ToolPermissionContext {
182 tool_name: "edit_file".to_string(),
183 input_value: path_str.to_string(),
184 };
185 return event_stream.authorize(
186 format!("{} (local settings)", input.display_description),
187 context,
188 cx,
189 );
190 }
191
192 // It's also possible that the global config dir is configured to be inside the project,
193 // so check for that edge case too.
194 // TODO this is broken when remoting
195 if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
196 && canonical_path.starts_with(paths::config_dir())
197 {
198 let context = crate::ToolPermissionContext {
199 tool_name: "edit_file".to_string(),
200 input_value: path_str.to_string(),
201 };
202 return event_stream.authorize(
203 format!("{} (global settings)", input.display_description),
204 context,
205 cx,
206 );
207 }
208
209 // Check if path is inside the global config directory
210 // First check if it's already inside project - if not, try to canonicalize
211 let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {
212 thread.project().read(cx).find_project_path(&input.path, cx)
213 }) else {
214 return Task::ready(Err(anyhow!("thread was dropped")));
215 };
216
217 // If the path is inside the project, and it's not one of the above edge cases,
218 // then no confirmation is necessary. Otherwise, confirmation is necessary.
219 if project_path.is_some() {
220 Task::ready(Ok(()))
221 } else {
222 let context = crate::ToolPermissionContext {
223 tool_name: "edit_file".to_string(),
224 input_value: path_str.to_string(),
225 };
226 event_stream.authorize(&input.display_description, context, cx)
227 }
228 }
229}
230
231impl AgentTool for EditFileTool {
232 type Input = EditFileToolInput;
233 type Output = EditFileToolOutput;
234
235 fn name() -> &'static str {
236 "edit_file"
237 }
238
239 fn kind() -> acp::ToolKind {
240 acp::ToolKind::Edit
241 }
242
243 fn initial_title(
244 &self,
245 input: Result<Self::Input, serde_json::Value>,
246 cx: &mut App,
247 ) -> SharedString {
248 match input {
249 Ok(input) => self
250 .project
251 .read(cx)
252 .find_project_path(&input.path, cx)
253 .and_then(|project_path| {
254 self.project
255 .read(cx)
256 .short_full_path_for_project_path(&project_path, cx)
257 })
258 .unwrap_or(input.path.to_string_lossy().into_owned())
259 .into(),
260 Err(raw_input) => {
261 if let Some(input) =
262 serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
263 {
264 let path = input.path.trim();
265 if !path.is_empty() {
266 return self
267 .project
268 .read(cx)
269 .find_project_path(&input.path, cx)
270 .and_then(|project_path| {
271 self.project
272 .read(cx)
273 .short_full_path_for_project_path(&project_path, cx)
274 })
275 .unwrap_or(input.path)
276 .into();
277 }
278
279 let description = input.display_description.trim();
280 if !description.is_empty() {
281 return description.to_string().into();
282 }
283 }
284
285 DEFAULT_UI_TEXT.into()
286 }
287 }
288 }
289
290 fn run(
291 self: Arc<Self>,
292 input: Self::Input,
293 event_stream: ToolCallEventStream,
294 cx: &mut App,
295 ) -> Task<Result<Self::Output>> {
296 let Ok(project) = self
297 .thread
298 .read_with(cx, |thread, _cx| thread.project().clone())
299 else {
300 return Task::ready(Err(anyhow!("thread was dropped")));
301 };
302 let project_path = match resolve_path(&input, project.clone(), cx) {
303 Ok(path) => path,
304 Err(err) => return Task::ready(Err(anyhow!(err))),
305 };
306 let abs_path = project.read(cx).absolute_path(&project_path, cx);
307 if let Some(abs_path) = abs_path.clone() {
308 event_stream.update_fields(
309 ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
310 );
311 }
312
313 let authorize = self.authorize(&input, &event_stream, cx);
314 cx.spawn(async move |cx: &mut AsyncApp| {
315 authorize.await?;
316
317 let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
318 let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
319 (request, thread.model().cloned(), thread.action_log().clone())
320 })?;
321 let request = request?;
322 let model = model.context("No language model configured")?;
323
324 let edit_format = EditFormat::from_model(model.clone())?;
325 let edit_agent = EditAgent::new(
326 model,
327 project.clone(),
328 action_log.clone(),
329 self.templates.clone(),
330 edit_format,
331 );
332
333 let buffer = project
334 .update(cx, |project, cx| {
335 project.open_buffer(project_path.clone(), cx)
336 })
337 .await?;
338
339 // Check if the file has been modified since the agent last read it
340 if let Some(abs_path) = abs_path.as_ref() {
341 let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
342 let last_read = thread.file_read_times.get(abs_path).copied();
343 let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
344 let dirty = buffer.read(cx).is_dirty();
345 let has_save = thread.has_tool("save_file");
346 let has_restore = thread.has_tool("restore_file_from_disk");
347 (last_read, current, dirty, has_save, has_restore)
348 })?;
349
350 // Check for unsaved changes first - these indicate modifications we don't know about
351 if is_dirty {
352 let message = match (has_save_tool, has_restore_tool) {
353 (true, true) => {
354 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
355 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
356 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."
357 }
358 (true, false) => {
359 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
360 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
361 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
362 }
363 (false, true) => {
364 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
365 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
366 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."
367 }
368 (false, false) => {
369 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
370 then ask them to save or revert the file manually and inform you when it's ok to proceed."
371 }
372 };
373 anyhow::bail!("{}", message);
374 }
375
376 // Check if the file was modified on disk since we last read it
377 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
378 // MTime can be unreliable for comparisons, so our newtype intentionally
379 // doesn't support comparing them. If the mtime at all different
380 // (which could be because of a modification or because e.g. system clock changed),
381 // we pessimistically assume it was modified.
382 if current != last_read {
383 anyhow::bail!(
384 "The file {} has been modified since you last read it. \
385 Please read the file again to get the current state before editing it.",
386 input.path.display()
387 );
388 }
389 }
390 }
391
392 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
393 event_stream.update_diff(diff.clone());
394 let _finalize_diff = util::defer({
395 let diff = diff.downgrade();
396 let mut cx = cx.clone();
397 move || {
398 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
399 }
400 });
401
402 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
403 let old_text = cx
404 .background_spawn({
405 let old_snapshot = old_snapshot.clone();
406 async move { Arc::new(old_snapshot.text()) }
407 })
408 .await;
409
410 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
411 edit_agent.edit(
412 buffer.clone(),
413 input.display_description.clone(),
414 &request,
415 cx,
416 )
417 } else {
418 edit_agent.overwrite(
419 buffer.clone(),
420 input.display_description.clone(),
421 &request,
422 cx,
423 )
424 };
425
426 let mut hallucinated_old_text = false;
427 let mut ambiguous_ranges = Vec::new();
428 let mut emitted_location = false;
429 loop {
430 let event = futures::select! {
431 event = events.next().fuse() => match event {
432 Some(event) => event,
433 None => break,
434 },
435 _ = event_stream.cancelled_by_user().fuse() => {
436 anyhow::bail!("Edit cancelled by user");
437 }
438 };
439 match event {
440 EditAgentOutputEvent::Edited(range) => {
441 if !emitted_location {
442 let line = Some(buffer.update(cx, |buffer, _cx| {
443 range.start.to_point(&buffer.snapshot()).row
444 }));
445 if let Some(abs_path) = abs_path.clone() {
446 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
447 }
448 emitted_location = true;
449 }
450 },
451 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
452 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
453 EditAgentOutputEvent::ResolvingEditRange(range) => {
454 diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
455 // if !emitted_location {
456 // let line = buffer.update(cx, |buffer, _cx| {
457 // range.start.to_point(&buffer.snapshot()).row
458 // }).ok();
459 // if let Some(abs_path) = abs_path.clone() {
460 // event_stream.update_fields(ToolCallUpdateFields {
461 // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
462 // ..Default::default()
463 // });
464 // }
465 // }
466 }
467 }
468 }
469
470 // If format_on_save is enabled, format the buffer
471 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
472 let settings = language_settings::language_settings(
473 buffer.language().map(|l| l.name()),
474 buffer.file(),
475 cx,
476 );
477 settings.format_on_save != FormatOnSave::Off
478 });
479
480 let edit_agent_output = output.await?;
481
482 if format_on_save_enabled {
483 action_log.update(cx, |log, cx| {
484 log.buffer_edited(buffer.clone(), cx);
485 });
486
487 let format_task = project.update(cx, |project, cx| {
488 project.format(
489 HashSet::from_iter([buffer.clone()]),
490 LspFormatTarget::Buffers,
491 false, // Don't push to history since the tool did it.
492 FormatTrigger::Save,
493 cx,
494 )
495 });
496 format_task.await.log_err();
497 }
498
499 project
500 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
501 .await?;
502
503 action_log.update(cx, |log, cx| {
504 log.buffer_edited(buffer.clone(), cx);
505 });
506
507 // Update the recorded read time after a successful edit so consecutive edits work
508 if let Some(abs_path) = abs_path.as_ref() {
509 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
510 buffer.file().and_then(|file| file.disk_state().mtime())
511 }) {
512 self.thread.update(cx, |thread, _| {
513 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
514 })?;
515 }
516 }
517
518 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
519 let (new_text, unified_diff) = cx
520 .background_spawn({
521 let new_snapshot = new_snapshot.clone();
522 let old_text = old_text.clone();
523 async move {
524 let new_text = new_snapshot.text();
525 let diff = language::unified_diff(&old_text, &new_text);
526 (new_text, diff)
527 }
528 })
529 .await;
530
531 let input_path = input.path.display();
532 if unified_diff.is_empty() {
533 anyhow::ensure!(
534 !hallucinated_old_text,
535 formatdoc! {"
536 Some edits were produced but none of them could be applied.
537 Read the relevant sections of {input_path} again so that
538 I can perform the requested edits.
539 "}
540 );
541 anyhow::ensure!(
542 ambiguous_ranges.is_empty(),
543 {
544 let line_numbers = ambiguous_ranges
545 .iter()
546 .map(|range| range.start.to_string())
547 .collect::<Vec<_>>()
548 .join(", ");
549 formatdoc! {"
550 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
551 relevant sections of {input_path} again and extend <old_text> so
552 that I can perform the requested edits.
553 "}
554 }
555 );
556 }
557
558 Ok(EditFileToolOutput {
559 input_path: input.path,
560 new_text,
561 old_text,
562 diff: unified_diff,
563 edit_agent_output,
564 })
565 })
566 }
567
568 fn replay(
569 &self,
570 _input: Self::Input,
571 output: Self::Output,
572 event_stream: ToolCallEventStream,
573 cx: &mut App,
574 ) -> Result<()> {
575 event_stream.update_diff(cx.new(|cx| {
576 Diff::finalized(
577 output.input_path.to_string_lossy().into_owned(),
578 Some(output.old_text.to_string()),
579 output.new_text,
580 self.language_registry.clone(),
581 cx,
582 )
583 }));
584 Ok(())
585 }
586
587 fn rebind_thread(
588 &self,
589 new_thread: gpui::WeakEntity<crate::Thread>,
590 ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
591 Some(self.with_thread(new_thread).erase())
592 }
593}
594
595/// Validate that the file path is valid, meaning:
596///
597/// - For `edit` and `overwrite`, the path must point to an existing file.
598/// - For `create`, the file must not already exist, but it's parent dir must exist.
599fn resolve_path(
600 input: &EditFileToolInput,
601 project: Entity<Project>,
602 cx: &mut App,
603) -> Result<ProjectPath> {
604 let project = project.read(cx);
605
606 match input.mode {
607 EditFileMode::Edit | EditFileMode::Overwrite => {
608 let path = project
609 .find_project_path(&input.path, cx)
610 .context("Can't edit file: path not found")?;
611
612 let entry = project
613 .entry_for_path(&path, cx)
614 .context("Can't edit file: path not found")?;
615
616 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
617 Ok(path)
618 }
619
620 EditFileMode::Create => {
621 if let Some(path) = project.find_project_path(&input.path, cx) {
622 anyhow::ensure!(
623 project.entry_for_path(&path, cx).is_none(),
624 "Can't create file: file already exists"
625 );
626 }
627
628 let parent_path = input
629 .path
630 .parent()
631 .context("Can't create file: incorrect path")?;
632
633 let parent_project_path = project.find_project_path(&parent_path, cx);
634
635 let parent_entry = parent_project_path
636 .as_ref()
637 .and_then(|path| project.entry_for_path(path, cx))
638 .context("Can't create file: parent directory doesn't exist")?;
639
640 anyhow::ensure!(
641 parent_entry.is_dir(),
642 "Can't create file: parent is not a directory"
643 );
644
645 let file_name = input
646 .path
647 .file_name()
648 .and_then(|file_name| file_name.to_str())
649 .and_then(|file_name| RelPath::unix(file_name).ok())
650 .context("Can't create file: invalid filename")?;
651
652 let new_file_path = parent_project_path.map(|parent| ProjectPath {
653 path: parent.path.join(file_name),
654 ..parent
655 });
656
657 new_file_path.context("Can't create file")
658 }
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::{ContextServerRegistry, Templates};
666 use fs::Fs;
667 use gpui::{TestAppContext, UpdateGlobal};
668 use language_model::fake_provider::FakeLanguageModel;
669 use prompt_store::ProjectContext;
670 use serde_json::json;
671 use settings::SettingsStore;
672 use util::{path, rel_path::rel_path};
673
674 #[gpui::test]
675 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
676 init_test(cx);
677
678 let fs = project::FakeFs::new(cx.executor());
679 fs.insert_tree("/root", json!({})).await;
680 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
681 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
682 let context_server_registry =
683 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
684 let model = Arc::new(FakeLanguageModel::default());
685 let thread = cx.new(|cx| {
686 Thread::new(
687 project.clone(),
688 cx.new(|_cx| ProjectContext::default()),
689 context_server_registry,
690 Templates::new(),
691 Some(model),
692 cx,
693 )
694 });
695 let result = cx
696 .update(|cx| {
697 let input = EditFileToolInput {
698 display_description: "Some edit".into(),
699 path: "root/nonexistent_file.txt".into(),
700 mode: EditFileMode::Edit,
701 };
702 Arc::new(EditFileTool::new(
703 project,
704 thread.downgrade(),
705 language_registry,
706 Templates::new(),
707 ))
708 .run(input, ToolCallEventStream::test().0, cx)
709 })
710 .await;
711 assert_eq!(
712 result.unwrap_err().to_string(),
713 "Can't edit file: path not found"
714 );
715 }
716
717 #[gpui::test]
718 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
719 let mode = &EditFileMode::Create;
720
721 let result = test_resolve_path(mode, "root/new.txt", cx);
722 assert_resolved_path_eq(result.await, rel_path("new.txt"));
723
724 let result = test_resolve_path(mode, "new.txt", cx);
725 assert_resolved_path_eq(result.await, rel_path("new.txt"));
726
727 let result = test_resolve_path(mode, "dir/new.txt", cx);
728 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
729
730 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
731 assert_eq!(
732 result.await.unwrap_err().to_string(),
733 "Can't create file: file already exists"
734 );
735
736 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
737 assert_eq!(
738 result.await.unwrap_err().to_string(),
739 "Can't create file: parent directory doesn't exist"
740 );
741 }
742
743 #[gpui::test]
744 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
745 let mode = &EditFileMode::Edit;
746
747 let path_with_root = "root/dir/subdir/existing.txt";
748 let path_without_root = "dir/subdir/existing.txt";
749 let result = test_resolve_path(mode, path_with_root, cx);
750 assert_resolved_path_eq(result.await, rel_path(path_without_root));
751
752 let result = test_resolve_path(mode, path_without_root, cx);
753 assert_resolved_path_eq(result.await, rel_path(path_without_root));
754
755 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
756 assert_eq!(
757 result.await.unwrap_err().to_string(),
758 "Can't edit file: path not found"
759 );
760
761 let result = test_resolve_path(mode, "root/dir", cx);
762 assert_eq!(
763 result.await.unwrap_err().to_string(),
764 "Can't edit file: path is a directory"
765 );
766 }
767
768 async fn test_resolve_path(
769 mode: &EditFileMode,
770 path: &str,
771 cx: &mut TestAppContext,
772 ) -> anyhow::Result<ProjectPath> {
773 init_test(cx);
774
775 let fs = project::FakeFs::new(cx.executor());
776 fs.insert_tree(
777 "/root",
778 json!({
779 "dir": {
780 "subdir": {
781 "existing.txt": "hello"
782 }
783 }
784 }),
785 )
786 .await;
787 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
788
789 let input = EditFileToolInput {
790 display_description: "Some edit".into(),
791 path: path.into(),
792 mode: mode.clone(),
793 };
794
795 cx.update(|cx| resolve_path(&input, project, cx))
796 }
797
798 #[track_caller]
799 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
800 let actual = path.expect("Should return valid path").path;
801 assert_eq!(actual.as_ref(), expected);
802 }
803
804 #[gpui::test]
805 async fn test_format_on_save(cx: &mut TestAppContext) {
806 init_test(cx);
807
808 let fs = project::FakeFs::new(cx.executor());
809 fs.insert_tree("/root", json!({"src": {}})).await;
810
811 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
812
813 // Set up a Rust language with LSP formatting support
814 let rust_language = Arc::new(language::Language::new(
815 language::LanguageConfig {
816 name: "Rust".into(),
817 matcher: language::LanguageMatcher {
818 path_suffixes: vec!["rs".to_string()],
819 ..Default::default()
820 },
821 ..Default::default()
822 },
823 None,
824 ));
825
826 // Register the language and fake LSP
827 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
828 language_registry.add(rust_language);
829
830 let mut fake_language_servers = language_registry.register_fake_lsp(
831 "Rust",
832 language::FakeLspAdapter {
833 capabilities: lsp::ServerCapabilities {
834 document_formatting_provider: Some(lsp::OneOf::Left(true)),
835 ..Default::default()
836 },
837 ..Default::default()
838 },
839 );
840
841 // Create the file
842 fs.save(
843 path!("/root/src/main.rs").as_ref(),
844 &"initial content".into(),
845 language::LineEnding::Unix,
846 )
847 .await
848 .unwrap();
849
850 // Open the buffer to trigger LSP initialization
851 let buffer = project
852 .update(cx, |project, cx| {
853 project.open_local_buffer(path!("/root/src/main.rs"), cx)
854 })
855 .await
856 .unwrap();
857
858 // Register the buffer with language servers
859 let _handle = project.update(cx, |project, cx| {
860 project.register_buffer_with_language_servers(&buffer, cx)
861 });
862
863 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
864 const FORMATTED_CONTENT: &str =
865 "This file was formatted by the fake formatter in the test.\n";
866
867 // Get the fake language server and set up formatting handler
868 let fake_language_server = fake_language_servers.next().await.unwrap();
869 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
870 |_, _| async move {
871 Ok(Some(vec![lsp::TextEdit {
872 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
873 new_text: FORMATTED_CONTENT.to_string(),
874 }]))
875 }
876 });
877
878 let context_server_registry =
879 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
880 let model = Arc::new(FakeLanguageModel::default());
881 let thread = cx.new(|cx| {
882 Thread::new(
883 project.clone(),
884 cx.new(|_cx| ProjectContext::default()),
885 context_server_registry,
886 Templates::new(),
887 Some(model.clone()),
888 cx,
889 )
890 });
891
892 // First, test with format_on_save enabled
893 cx.update(|cx| {
894 SettingsStore::update_global(cx, |store, cx| {
895 store.update_user_settings(cx, |settings| {
896 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
897 settings.project.all_languages.defaults.formatter =
898 Some(language::language_settings::FormatterList::default());
899 });
900 });
901 });
902
903 // Have the model stream unformatted content
904 let edit_result = {
905 let edit_task = cx.update(|cx| {
906 let input = EditFileToolInput {
907 display_description: "Create main function".into(),
908 path: "root/src/main.rs".into(),
909 mode: EditFileMode::Overwrite,
910 };
911 Arc::new(EditFileTool::new(
912 project.clone(),
913 thread.downgrade(),
914 language_registry.clone(),
915 Templates::new(),
916 ))
917 .run(input, ToolCallEventStream::test().0, cx)
918 });
919
920 // Stream the unformatted content
921 cx.executor().run_until_parked();
922 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
923 model.end_last_completion_stream();
924
925 edit_task.await
926 };
927 assert!(edit_result.is_ok());
928
929 // Wait for any async operations (e.g. formatting) to complete
930 cx.executor().run_until_parked();
931
932 // Read the file to verify it was formatted automatically
933 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
934 assert_eq!(
935 // Ignore carriage returns on Windows
936 new_content.replace("\r\n", "\n"),
937 FORMATTED_CONTENT,
938 "Code should be formatted when format_on_save is enabled"
939 );
940
941 let stale_buffer_count = thread
942 .read_with(cx, |thread, _cx| thread.action_log.clone())
943 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
944
945 assert_eq!(
946 stale_buffer_count, 0,
947 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
948 This causes the agent to think the file was modified externally when it was just formatted.",
949 stale_buffer_count
950 );
951
952 // Next, test with format_on_save disabled
953 cx.update(|cx| {
954 SettingsStore::update_global(cx, |store, cx| {
955 store.update_user_settings(cx, |settings| {
956 settings.project.all_languages.defaults.format_on_save =
957 Some(FormatOnSave::Off);
958 });
959 });
960 });
961
962 // Stream unformatted edits again
963 let edit_result = {
964 let edit_task = cx.update(|cx| {
965 let input = EditFileToolInput {
966 display_description: "Update main function".into(),
967 path: "root/src/main.rs".into(),
968 mode: EditFileMode::Overwrite,
969 };
970 Arc::new(EditFileTool::new(
971 project.clone(),
972 thread.downgrade(),
973 language_registry,
974 Templates::new(),
975 ))
976 .run(input, ToolCallEventStream::test().0, cx)
977 });
978
979 // Stream the unformatted content
980 cx.executor().run_until_parked();
981 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
982 model.end_last_completion_stream();
983
984 edit_task.await
985 };
986 assert!(edit_result.is_ok());
987
988 // Wait for any async operations (e.g. formatting) to complete
989 cx.executor().run_until_parked();
990
991 // Verify the file was not formatted
992 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
993 assert_eq!(
994 // Ignore carriage returns on Windows
995 new_content.replace("\r\n", "\n"),
996 UNFORMATTED_CONTENT,
997 "Code should not be formatted when format_on_save is disabled"
998 );
999 }
1000
1001 #[gpui::test]
1002 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1003 init_test(cx);
1004
1005 let fs = project::FakeFs::new(cx.executor());
1006 fs.insert_tree("/root", json!({"src": {}})).await;
1007
1008 // Create a simple file with trailing whitespace
1009 fs.save(
1010 path!("/root/src/main.rs").as_ref(),
1011 &"initial content".into(),
1012 language::LineEnding::Unix,
1013 )
1014 .await
1015 .unwrap();
1016
1017 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1018 let context_server_registry =
1019 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1020 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1021 let model = Arc::new(FakeLanguageModel::default());
1022 let thread = cx.new(|cx| {
1023 Thread::new(
1024 project.clone(),
1025 cx.new(|_cx| ProjectContext::default()),
1026 context_server_registry,
1027 Templates::new(),
1028 Some(model.clone()),
1029 cx,
1030 )
1031 });
1032
1033 // First, test with remove_trailing_whitespace_on_save enabled
1034 cx.update(|cx| {
1035 SettingsStore::update_global(cx, |store, cx| {
1036 store.update_user_settings(cx, |settings| {
1037 settings
1038 .project
1039 .all_languages
1040 .defaults
1041 .remove_trailing_whitespace_on_save = Some(true);
1042 });
1043 });
1044 });
1045
1046 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1047 "fn main() { \n println!(\"Hello!\"); \n}\n";
1048
1049 // Have the model stream content that contains trailing whitespace
1050 let edit_result = {
1051 let edit_task = cx.update(|cx| {
1052 let input = EditFileToolInput {
1053 display_description: "Create main function".into(),
1054 path: "root/src/main.rs".into(),
1055 mode: EditFileMode::Overwrite,
1056 };
1057 Arc::new(EditFileTool::new(
1058 project.clone(),
1059 thread.downgrade(),
1060 language_registry.clone(),
1061 Templates::new(),
1062 ))
1063 .run(input, ToolCallEventStream::test().0, cx)
1064 });
1065
1066 // Stream the content with trailing whitespace
1067 cx.executor().run_until_parked();
1068 model.send_last_completion_stream_text_chunk(
1069 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1070 );
1071 model.end_last_completion_stream();
1072
1073 edit_task.await
1074 };
1075 assert!(edit_result.is_ok());
1076
1077 // Wait for any async operations (e.g. formatting) to complete
1078 cx.executor().run_until_parked();
1079
1080 // Read the file to verify trailing whitespace was removed automatically
1081 assert_eq!(
1082 // Ignore carriage returns on Windows
1083 fs.load(path!("/root/src/main.rs").as_ref())
1084 .await
1085 .unwrap()
1086 .replace("\r\n", "\n"),
1087 "fn main() {\n println!(\"Hello!\");\n}\n",
1088 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1089 );
1090
1091 // Next, test with remove_trailing_whitespace_on_save disabled
1092 cx.update(|cx| {
1093 SettingsStore::update_global(cx, |store, cx| {
1094 store.update_user_settings(cx, |settings| {
1095 settings
1096 .project
1097 .all_languages
1098 .defaults
1099 .remove_trailing_whitespace_on_save = Some(false);
1100 });
1101 });
1102 });
1103
1104 // Stream edits again with trailing whitespace
1105 let edit_result = {
1106 let edit_task = cx.update(|cx| {
1107 let input = EditFileToolInput {
1108 display_description: "Update main function".into(),
1109 path: "root/src/main.rs".into(),
1110 mode: EditFileMode::Overwrite,
1111 };
1112 Arc::new(EditFileTool::new(
1113 project.clone(),
1114 thread.downgrade(),
1115 language_registry,
1116 Templates::new(),
1117 ))
1118 .run(input, ToolCallEventStream::test().0, cx)
1119 });
1120
1121 // Stream the content with trailing whitespace
1122 cx.executor().run_until_parked();
1123 model.send_last_completion_stream_text_chunk(
1124 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1125 );
1126 model.end_last_completion_stream();
1127
1128 edit_task.await
1129 };
1130 assert!(edit_result.is_ok());
1131
1132 // Wait for any async operations (e.g. formatting) to complete
1133 cx.executor().run_until_parked();
1134
1135 // Verify the file still has trailing whitespace
1136 // Read the file again - it should still have trailing whitespace
1137 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1138 assert_eq!(
1139 // Ignore carriage returns on Windows
1140 final_content.replace("\r\n", "\n"),
1141 CONTENT_WITH_TRAILING_WHITESPACE,
1142 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1143 );
1144 }
1145
1146 #[gpui::test]
1147 async fn test_authorize(cx: &mut TestAppContext) {
1148 init_test(cx);
1149 let fs = project::FakeFs::new(cx.executor());
1150 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1151 let context_server_registry =
1152 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1153 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1154 let model = Arc::new(FakeLanguageModel::default());
1155 let thread = cx.new(|cx| {
1156 Thread::new(
1157 project.clone(),
1158 cx.new(|_cx| ProjectContext::default()),
1159 context_server_registry,
1160 Templates::new(),
1161 Some(model.clone()),
1162 cx,
1163 )
1164 });
1165 let tool = Arc::new(EditFileTool::new(
1166 project.clone(),
1167 thread.downgrade(),
1168 language_registry,
1169 Templates::new(),
1170 ));
1171 fs.insert_tree("/root", json!({})).await;
1172
1173 // Test 1: Path with .zed component should require confirmation
1174 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1175 let _auth = cx.update(|cx| {
1176 tool.authorize(
1177 &EditFileToolInput {
1178 display_description: "test 1".into(),
1179 path: ".zed/settings.json".into(),
1180 mode: EditFileMode::Edit,
1181 },
1182 &stream_tx,
1183 cx,
1184 )
1185 });
1186
1187 let event = stream_rx.expect_authorization().await;
1188 assert_eq!(
1189 event.tool_call.fields.title,
1190 Some("test 1 (local settings)".into())
1191 );
1192
1193 // Test 2: Path outside project should require confirmation
1194 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1195 let _auth = cx.update(|cx| {
1196 tool.authorize(
1197 &EditFileToolInput {
1198 display_description: "test 2".into(),
1199 path: "/etc/hosts".into(),
1200 mode: EditFileMode::Edit,
1201 },
1202 &stream_tx,
1203 cx,
1204 )
1205 });
1206
1207 let event = stream_rx.expect_authorization().await;
1208 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1209
1210 // Test 3: Relative path without .zed should not require confirmation
1211 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1212 cx.update(|cx| {
1213 tool.authorize(
1214 &EditFileToolInput {
1215 display_description: "test 3".into(),
1216 path: "root/src/main.rs".into(),
1217 mode: EditFileMode::Edit,
1218 },
1219 &stream_tx,
1220 cx,
1221 )
1222 })
1223 .await
1224 .unwrap();
1225 assert!(stream_rx.try_next().is_err());
1226
1227 // Test 4: Path with .zed in the middle should require confirmation
1228 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1229 let _auth = cx.update(|cx| {
1230 tool.authorize(
1231 &EditFileToolInput {
1232 display_description: "test 4".into(),
1233 path: "root/.zed/tasks.json".into(),
1234 mode: EditFileMode::Edit,
1235 },
1236 &stream_tx,
1237 cx,
1238 )
1239 });
1240 let event = stream_rx.expect_authorization().await;
1241 assert_eq!(
1242 event.tool_call.fields.title,
1243 Some("test 4 (local settings)".into())
1244 );
1245
1246 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1247 cx.update(|cx| {
1248 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1249 settings.always_allow_tool_actions = true;
1250 agent_settings::AgentSettings::override_global(settings, cx);
1251 });
1252
1253 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1254 cx.update(|cx| {
1255 tool.authorize(
1256 &EditFileToolInput {
1257 display_description: "test 5.1".into(),
1258 path: ".zed/settings.json".into(),
1259 mode: EditFileMode::Edit,
1260 },
1261 &stream_tx,
1262 cx,
1263 )
1264 })
1265 .await
1266 .unwrap();
1267 assert!(stream_rx.try_next().is_err());
1268
1269 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1270 cx.update(|cx| {
1271 tool.authorize(
1272 &EditFileToolInput {
1273 display_description: "test 5.2".into(),
1274 path: "/etc/hosts".into(),
1275 mode: EditFileMode::Edit,
1276 },
1277 &stream_tx,
1278 cx,
1279 )
1280 })
1281 .await
1282 .unwrap();
1283 assert!(stream_rx.try_next().is_err());
1284 }
1285
1286 #[gpui::test]
1287 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1288 init_test(cx);
1289 let fs = project::FakeFs::new(cx.executor());
1290 fs.insert_tree("/project", json!({})).await;
1291 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1292 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1293 let context_server_registry =
1294 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1295 let model = Arc::new(FakeLanguageModel::default());
1296 let thread = cx.new(|cx| {
1297 Thread::new(
1298 project.clone(),
1299 cx.new(|_cx| ProjectContext::default()),
1300 context_server_registry,
1301 Templates::new(),
1302 Some(model.clone()),
1303 cx,
1304 )
1305 });
1306 let tool = Arc::new(EditFileTool::new(
1307 project.clone(),
1308 thread.downgrade(),
1309 language_registry,
1310 Templates::new(),
1311 ));
1312
1313 // Test global config paths - these should require confirmation if they exist and are outside the project
1314 let test_cases = vec![
1315 (
1316 "/etc/hosts",
1317 true,
1318 "System file should require confirmation",
1319 ),
1320 (
1321 "/usr/local/bin/script",
1322 true,
1323 "System bin file should require confirmation",
1324 ),
1325 (
1326 "project/normal_file.rs",
1327 false,
1328 "Normal project file should not require confirmation",
1329 ),
1330 ];
1331
1332 for (path, should_confirm, description) in test_cases {
1333 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1334 let auth = cx.update(|cx| {
1335 tool.authorize(
1336 &EditFileToolInput {
1337 display_description: "Edit file".into(),
1338 path: path.into(),
1339 mode: EditFileMode::Edit,
1340 },
1341 &stream_tx,
1342 cx,
1343 )
1344 });
1345
1346 if should_confirm {
1347 stream_rx.expect_authorization().await;
1348 } else {
1349 auth.await.unwrap();
1350 assert!(
1351 stream_rx.try_next().is_err(),
1352 "Failed for case: {} - path: {} - expected no confirmation but got one",
1353 description,
1354 path
1355 );
1356 }
1357 }
1358 }
1359
1360 #[gpui::test]
1361 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1362 init_test(cx);
1363 let fs = project::FakeFs::new(cx.executor());
1364
1365 // Create multiple worktree directories
1366 fs.insert_tree(
1367 "/workspace/frontend",
1368 json!({
1369 "src": {
1370 "main.js": "console.log('frontend');"
1371 }
1372 }),
1373 )
1374 .await;
1375 fs.insert_tree(
1376 "/workspace/backend",
1377 json!({
1378 "src": {
1379 "main.rs": "fn main() {}"
1380 }
1381 }),
1382 )
1383 .await;
1384 fs.insert_tree(
1385 "/workspace/shared",
1386 json!({
1387 ".zed": {
1388 "settings.json": "{}"
1389 }
1390 }),
1391 )
1392 .await;
1393
1394 // Create project with multiple worktrees
1395 let project = Project::test(
1396 fs.clone(),
1397 [
1398 path!("/workspace/frontend").as_ref(),
1399 path!("/workspace/backend").as_ref(),
1400 path!("/workspace/shared").as_ref(),
1401 ],
1402 cx,
1403 )
1404 .await;
1405 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1406 let context_server_registry =
1407 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1408 let model = Arc::new(FakeLanguageModel::default());
1409 let thread = cx.new(|cx| {
1410 Thread::new(
1411 project.clone(),
1412 cx.new(|_cx| ProjectContext::default()),
1413 context_server_registry.clone(),
1414 Templates::new(),
1415 Some(model.clone()),
1416 cx,
1417 )
1418 });
1419 let tool = Arc::new(EditFileTool::new(
1420 project.clone(),
1421 thread.downgrade(),
1422 language_registry,
1423 Templates::new(),
1424 ));
1425
1426 // Test files in different worktrees
1427 let test_cases = vec![
1428 ("frontend/src/main.js", false, "File in first worktree"),
1429 ("backend/src/main.rs", false, "File in second worktree"),
1430 (
1431 "shared/.zed/settings.json",
1432 true,
1433 ".zed file in third worktree",
1434 ),
1435 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1436 (
1437 "../outside/file.txt",
1438 true,
1439 "Relative path outside worktrees",
1440 ),
1441 ];
1442
1443 for (path, should_confirm, description) in test_cases {
1444 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1445 let auth = cx.update(|cx| {
1446 tool.authorize(
1447 &EditFileToolInput {
1448 display_description: "Edit file".into(),
1449 path: path.into(),
1450 mode: EditFileMode::Edit,
1451 },
1452 &stream_tx,
1453 cx,
1454 )
1455 });
1456
1457 if should_confirm {
1458 stream_rx.expect_authorization().await;
1459 } else {
1460 auth.await.unwrap();
1461 assert!(
1462 stream_rx.try_next().is_err(),
1463 "Failed for case: {} - path: {} - expected no confirmation but got one",
1464 description,
1465 path
1466 );
1467 }
1468 }
1469 }
1470
1471 #[gpui::test]
1472 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1473 init_test(cx);
1474 let fs = project::FakeFs::new(cx.executor());
1475 fs.insert_tree(
1476 "/project",
1477 json!({
1478 ".zed": {
1479 "settings.json": "{}"
1480 },
1481 "src": {
1482 ".zed": {
1483 "local.json": "{}"
1484 }
1485 }
1486 }),
1487 )
1488 .await;
1489 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1490 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1491 let context_server_registry =
1492 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1493 let model = Arc::new(FakeLanguageModel::default());
1494 let thread = cx.new(|cx| {
1495 Thread::new(
1496 project.clone(),
1497 cx.new(|_cx| ProjectContext::default()),
1498 context_server_registry.clone(),
1499 Templates::new(),
1500 Some(model.clone()),
1501 cx,
1502 )
1503 });
1504 let tool = Arc::new(EditFileTool::new(
1505 project.clone(),
1506 thread.downgrade(),
1507 language_registry,
1508 Templates::new(),
1509 ));
1510
1511 // Test edge cases
1512 let test_cases = vec![
1513 // Empty path - find_project_path returns Some for empty paths
1514 ("", false, "Empty path is treated as project root"),
1515 // Root directory
1516 ("/", true, "Root directory should be outside project"),
1517 // Parent directory references - find_project_path resolves these
1518 (
1519 "project/../other",
1520 true,
1521 "Path with .. that goes outside of root directory",
1522 ),
1523 (
1524 "project/./src/file.rs",
1525 false,
1526 "Path with . should work normally",
1527 ),
1528 // Windows-style paths (if on Windows)
1529 #[cfg(target_os = "windows")]
1530 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1531 #[cfg(target_os = "windows")]
1532 ("project\\src\\main.rs", false, "Windows-style project path"),
1533 ];
1534
1535 for (path, should_confirm, description) in test_cases {
1536 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1537 let auth = cx.update(|cx| {
1538 tool.authorize(
1539 &EditFileToolInput {
1540 display_description: "Edit file".into(),
1541 path: path.into(),
1542 mode: EditFileMode::Edit,
1543 },
1544 &stream_tx,
1545 cx,
1546 )
1547 });
1548
1549 cx.run_until_parked();
1550
1551 if should_confirm {
1552 stream_rx.expect_authorization().await;
1553 } else {
1554 assert!(
1555 stream_rx.try_next().is_err(),
1556 "Failed for case: {} - path: {} - expected no confirmation but got one",
1557 description,
1558 path
1559 );
1560 auth.await.unwrap();
1561 }
1562 }
1563 }
1564
1565 #[gpui::test]
1566 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1567 init_test(cx);
1568 let fs = project::FakeFs::new(cx.executor());
1569 fs.insert_tree(
1570 "/project",
1571 json!({
1572 "existing.txt": "content",
1573 ".zed": {
1574 "settings.json": "{}"
1575 }
1576 }),
1577 )
1578 .await;
1579 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1580 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1581 let context_server_registry =
1582 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1583 let model = Arc::new(FakeLanguageModel::default());
1584 let thread = cx.new(|cx| {
1585 Thread::new(
1586 project.clone(),
1587 cx.new(|_cx| ProjectContext::default()),
1588 context_server_registry.clone(),
1589 Templates::new(),
1590 Some(model.clone()),
1591 cx,
1592 )
1593 });
1594 let tool = Arc::new(EditFileTool::new(
1595 project.clone(),
1596 thread.downgrade(),
1597 language_registry,
1598 Templates::new(),
1599 ));
1600
1601 // Test different EditFileMode values
1602 let modes = vec![
1603 EditFileMode::Edit,
1604 EditFileMode::Create,
1605 EditFileMode::Overwrite,
1606 ];
1607
1608 for mode in modes {
1609 // Test .zed path with different modes
1610 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1611 let _auth = cx.update(|cx| {
1612 tool.authorize(
1613 &EditFileToolInput {
1614 display_description: "Edit settings".into(),
1615 path: "project/.zed/settings.json".into(),
1616 mode: mode.clone(),
1617 },
1618 &stream_tx,
1619 cx,
1620 )
1621 });
1622
1623 stream_rx.expect_authorization().await;
1624
1625 // Test outside path with different modes
1626 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1627 let _auth = cx.update(|cx| {
1628 tool.authorize(
1629 &EditFileToolInput {
1630 display_description: "Edit file".into(),
1631 path: "/outside/file.txt".into(),
1632 mode: mode.clone(),
1633 },
1634 &stream_tx,
1635 cx,
1636 )
1637 });
1638
1639 stream_rx.expect_authorization().await;
1640
1641 // Test normal path with different modes
1642 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1643 cx.update(|cx| {
1644 tool.authorize(
1645 &EditFileToolInput {
1646 display_description: "Edit file".into(),
1647 path: "project/normal.txt".into(),
1648 mode: mode.clone(),
1649 },
1650 &stream_tx,
1651 cx,
1652 )
1653 })
1654 .await
1655 .unwrap();
1656 assert!(stream_rx.try_next().is_err());
1657 }
1658 }
1659
1660 #[gpui::test]
1661 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1662 init_test(cx);
1663 let fs = project::FakeFs::new(cx.executor());
1664 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1665 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1666 let context_server_registry =
1667 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1668 let model = Arc::new(FakeLanguageModel::default());
1669 let thread = cx.new(|cx| {
1670 Thread::new(
1671 project.clone(),
1672 cx.new(|_cx| ProjectContext::default()),
1673 context_server_registry,
1674 Templates::new(),
1675 Some(model.clone()),
1676 cx,
1677 )
1678 });
1679 let tool = Arc::new(EditFileTool::new(
1680 project,
1681 thread.downgrade(),
1682 language_registry,
1683 Templates::new(),
1684 ));
1685
1686 cx.update(|cx| {
1687 // ...
1688 assert_eq!(
1689 tool.initial_title(
1690 Err(json!({
1691 "path": "src/main.rs",
1692 "display_description": "",
1693 "old_string": "old code",
1694 "new_string": "new code"
1695 })),
1696 cx
1697 ),
1698 "src/main.rs"
1699 );
1700 assert_eq!(
1701 tool.initial_title(
1702 Err(json!({
1703 "path": "",
1704 "display_description": "Fix error handling",
1705 "old_string": "old code",
1706 "new_string": "new code"
1707 })),
1708 cx
1709 ),
1710 "Fix error handling"
1711 );
1712 assert_eq!(
1713 tool.initial_title(
1714 Err(json!({
1715 "path": "src/main.rs",
1716 "display_description": "Fix error handling",
1717 "old_string": "old code",
1718 "new_string": "new code"
1719 })),
1720 cx
1721 ),
1722 "src/main.rs"
1723 );
1724 assert_eq!(
1725 tool.initial_title(
1726 Err(json!({
1727 "path": "",
1728 "display_description": "",
1729 "old_string": "old code",
1730 "new_string": "new code"
1731 })),
1732 cx
1733 ),
1734 DEFAULT_UI_TEXT
1735 );
1736 assert_eq!(
1737 tool.initial_title(Err(serde_json::Value::Null), cx),
1738 DEFAULT_UI_TEXT
1739 );
1740 });
1741 }
1742
1743 #[gpui::test]
1744 async fn test_diff_finalization(cx: &mut TestAppContext) {
1745 init_test(cx);
1746 let fs = project::FakeFs::new(cx.executor());
1747 fs.insert_tree("/", json!({"main.rs": ""})).await;
1748
1749 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1750 let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1751 let context_server_registry =
1752 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1753 let model = Arc::new(FakeLanguageModel::default());
1754 let thread = cx.new(|cx| {
1755 Thread::new(
1756 project.clone(),
1757 cx.new(|_cx| ProjectContext::default()),
1758 context_server_registry.clone(),
1759 Templates::new(),
1760 Some(model.clone()),
1761 cx,
1762 )
1763 });
1764
1765 // Ensure the diff is finalized after the edit completes.
1766 {
1767 let tool = Arc::new(EditFileTool::new(
1768 project.clone(),
1769 thread.downgrade(),
1770 languages.clone(),
1771 Templates::new(),
1772 ));
1773 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1774 let edit = cx.update(|cx| {
1775 tool.run(
1776 EditFileToolInput {
1777 display_description: "Edit file".into(),
1778 path: path!("/main.rs").into(),
1779 mode: EditFileMode::Edit,
1780 },
1781 stream_tx,
1782 cx,
1783 )
1784 });
1785 stream_rx.expect_update_fields().await;
1786 let diff = stream_rx.expect_diff().await;
1787 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1788 cx.run_until_parked();
1789 model.end_last_completion_stream();
1790 edit.await.unwrap();
1791 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1792 }
1793
1794 // Ensure the diff is finalized if an error occurs while editing.
1795 {
1796 model.forbid_requests();
1797 let tool = Arc::new(EditFileTool::new(
1798 project.clone(),
1799 thread.downgrade(),
1800 languages.clone(),
1801 Templates::new(),
1802 ));
1803 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1804 let edit = cx.update(|cx| {
1805 tool.run(
1806 EditFileToolInput {
1807 display_description: "Edit file".into(),
1808 path: path!("/main.rs").into(),
1809 mode: EditFileMode::Edit,
1810 },
1811 stream_tx,
1812 cx,
1813 )
1814 });
1815 stream_rx.expect_update_fields().await;
1816 let diff = stream_rx.expect_diff().await;
1817 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1818 edit.await.unwrap_err();
1819 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1820 model.allow_requests();
1821 }
1822
1823 // Ensure the diff is finalized if the tool call gets dropped.
1824 {
1825 let tool = Arc::new(EditFileTool::new(
1826 project.clone(),
1827 thread.downgrade(),
1828 languages.clone(),
1829 Templates::new(),
1830 ));
1831 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1832 let edit = cx.update(|cx| {
1833 tool.run(
1834 EditFileToolInput {
1835 display_description: "Edit file".into(),
1836 path: path!("/main.rs").into(),
1837 mode: EditFileMode::Edit,
1838 },
1839 stream_tx,
1840 cx,
1841 )
1842 });
1843 stream_rx.expect_update_fields().await;
1844 let diff = stream_rx.expect_diff().await;
1845 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1846 drop(edit);
1847 cx.run_until_parked();
1848 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1849 }
1850 }
1851
1852 #[gpui::test]
1853 async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1854 init_test(cx);
1855
1856 let fs = project::FakeFs::new(cx.executor());
1857 fs.insert_tree(
1858 "/root",
1859 json!({
1860 "test.txt": "original content"
1861 }),
1862 )
1863 .await;
1864 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1865 let context_server_registry =
1866 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1867 let model = Arc::new(FakeLanguageModel::default());
1868 let thread = cx.new(|cx| {
1869 Thread::new(
1870 project.clone(),
1871 cx.new(|_cx| ProjectContext::default()),
1872 context_server_registry,
1873 Templates::new(),
1874 Some(model.clone()),
1875 cx,
1876 )
1877 });
1878 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1879
1880 // Initially, file_read_times should be empty
1881 let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1882 assert!(is_empty, "file_read_times should start empty");
1883
1884 // Create read tool
1885 let read_tool = Arc::new(crate::ReadFileTool::new(
1886 thread.downgrade(),
1887 project.clone(),
1888 action_log,
1889 ));
1890
1891 // Read the file to record the read time
1892 cx.update(|cx| {
1893 read_tool.clone().run(
1894 crate::ReadFileToolInput {
1895 path: "root/test.txt".to_string(),
1896 start_line: None,
1897 end_line: None,
1898 },
1899 ToolCallEventStream::test().0,
1900 cx,
1901 )
1902 })
1903 .await
1904 .unwrap();
1905
1906 // Verify that file_read_times now contains an entry for the file
1907 let has_entry = thread.read_with(cx, |thread, _| {
1908 thread.file_read_times.len() == 1
1909 && thread
1910 .file_read_times
1911 .keys()
1912 .any(|path| path.ends_with("test.txt"))
1913 });
1914 assert!(
1915 has_entry,
1916 "file_read_times should contain an entry after reading the file"
1917 );
1918
1919 // Read the file again - should update the entry
1920 cx.update(|cx| {
1921 read_tool.clone().run(
1922 crate::ReadFileToolInput {
1923 path: "root/test.txt".to_string(),
1924 start_line: None,
1925 end_line: None,
1926 },
1927 ToolCallEventStream::test().0,
1928 cx,
1929 )
1930 })
1931 .await
1932 .unwrap();
1933
1934 // Should still have exactly one entry
1935 let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
1936 assert!(
1937 has_one_entry,
1938 "file_read_times should still have one entry after re-reading"
1939 );
1940 }
1941
1942 fn init_test(cx: &mut TestAppContext) {
1943 cx.update(|cx| {
1944 let settings_store = SettingsStore::test(cx);
1945 cx.set_global(settings_store);
1946 });
1947 }
1948
1949 #[gpui::test]
1950 async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
1951 init_test(cx);
1952
1953 let fs = project::FakeFs::new(cx.executor());
1954 fs.insert_tree(
1955 "/root",
1956 json!({
1957 "test.txt": "original content"
1958 }),
1959 )
1960 .await;
1961 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1962 let context_server_registry =
1963 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1964 let model = Arc::new(FakeLanguageModel::default());
1965 let thread = cx.new(|cx| {
1966 Thread::new(
1967 project.clone(),
1968 cx.new(|_cx| ProjectContext::default()),
1969 context_server_registry,
1970 Templates::new(),
1971 Some(model.clone()),
1972 cx,
1973 )
1974 });
1975 let languages = project.read_with(cx, |project, _| project.languages().clone());
1976 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1977
1978 let read_tool = Arc::new(crate::ReadFileTool::new(
1979 thread.downgrade(),
1980 project.clone(),
1981 action_log,
1982 ));
1983 let edit_tool = Arc::new(EditFileTool::new(
1984 project.clone(),
1985 thread.downgrade(),
1986 languages,
1987 Templates::new(),
1988 ));
1989
1990 // Read the file first
1991 cx.update(|cx| {
1992 read_tool.clone().run(
1993 crate::ReadFileToolInput {
1994 path: "root/test.txt".to_string(),
1995 start_line: None,
1996 end_line: None,
1997 },
1998 ToolCallEventStream::test().0,
1999 cx,
2000 )
2001 })
2002 .await
2003 .unwrap();
2004
2005 // First edit should work
2006 let edit_result = {
2007 let edit_task = cx.update(|cx| {
2008 edit_tool.clone().run(
2009 EditFileToolInput {
2010 display_description: "First edit".into(),
2011 path: "root/test.txt".into(),
2012 mode: EditFileMode::Edit,
2013 },
2014 ToolCallEventStream::test().0,
2015 cx,
2016 )
2017 });
2018
2019 cx.executor().run_until_parked();
2020 model.send_last_completion_stream_text_chunk(
2021 "<old_text>original content</old_text><new_text>modified content</new_text>"
2022 .to_string(),
2023 );
2024 model.end_last_completion_stream();
2025
2026 edit_task.await
2027 };
2028 assert!(
2029 edit_result.is_ok(),
2030 "First edit should succeed, got error: {:?}",
2031 edit_result.as_ref().err()
2032 );
2033
2034 // Second edit should also work because the edit updated the recorded read time
2035 let edit_result = {
2036 let edit_task = cx.update(|cx| {
2037 edit_tool.clone().run(
2038 EditFileToolInput {
2039 display_description: "Second edit".into(),
2040 path: "root/test.txt".into(),
2041 mode: EditFileMode::Edit,
2042 },
2043 ToolCallEventStream::test().0,
2044 cx,
2045 )
2046 });
2047
2048 cx.executor().run_until_parked();
2049 model.send_last_completion_stream_text_chunk(
2050 "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2051 );
2052 model.end_last_completion_stream();
2053
2054 edit_task.await
2055 };
2056 assert!(
2057 edit_result.is_ok(),
2058 "Second consecutive edit should succeed, got error: {:?}",
2059 edit_result.as_ref().err()
2060 );
2061 }
2062
2063 #[gpui::test]
2064 async fn test_external_modification_detected(cx: &mut TestAppContext) {
2065 init_test(cx);
2066
2067 let fs = project::FakeFs::new(cx.executor());
2068 fs.insert_tree(
2069 "/root",
2070 json!({
2071 "test.txt": "original content"
2072 }),
2073 )
2074 .await;
2075 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2076 let context_server_registry =
2077 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2078 let model = Arc::new(FakeLanguageModel::default());
2079 let thread = cx.new(|cx| {
2080 Thread::new(
2081 project.clone(),
2082 cx.new(|_cx| ProjectContext::default()),
2083 context_server_registry,
2084 Templates::new(),
2085 Some(model.clone()),
2086 cx,
2087 )
2088 });
2089 let languages = project.read_with(cx, |project, _| project.languages().clone());
2090 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2091
2092 let read_tool = Arc::new(crate::ReadFileTool::new(
2093 thread.downgrade(),
2094 project.clone(),
2095 action_log,
2096 ));
2097 let edit_tool = Arc::new(EditFileTool::new(
2098 project.clone(),
2099 thread.downgrade(),
2100 languages,
2101 Templates::new(),
2102 ));
2103
2104 // Read the file first
2105 cx.update(|cx| {
2106 read_tool.clone().run(
2107 crate::ReadFileToolInput {
2108 path: "root/test.txt".to_string(),
2109 start_line: None,
2110 end_line: None,
2111 },
2112 ToolCallEventStream::test().0,
2113 cx,
2114 )
2115 })
2116 .await
2117 .unwrap();
2118
2119 // Simulate external modification - advance time and save file
2120 cx.background_executor
2121 .advance_clock(std::time::Duration::from_secs(2));
2122 fs.save(
2123 path!("/root/test.txt").as_ref(),
2124 &"externally modified content".into(),
2125 language::LineEnding::Unix,
2126 )
2127 .await
2128 .unwrap();
2129
2130 // Reload the buffer to pick up the new mtime
2131 let project_path = project
2132 .read_with(cx, |project, cx| {
2133 project.find_project_path("root/test.txt", cx)
2134 })
2135 .expect("Should find project path");
2136 let buffer = project
2137 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2138 .await
2139 .unwrap();
2140 buffer
2141 .update(cx, |buffer, cx| buffer.reload(cx))
2142 .await
2143 .unwrap();
2144
2145 cx.executor().run_until_parked();
2146
2147 // Try to edit - should fail because file was modified externally
2148 let result = cx
2149 .update(|cx| {
2150 edit_tool.clone().run(
2151 EditFileToolInput {
2152 display_description: "Edit after external change".into(),
2153 path: "root/test.txt".into(),
2154 mode: EditFileMode::Edit,
2155 },
2156 ToolCallEventStream::test().0,
2157 cx,
2158 )
2159 })
2160 .await;
2161
2162 assert!(
2163 result.is_err(),
2164 "Edit should fail after external modification"
2165 );
2166 let error_msg = result.unwrap_err().to_string();
2167 assert!(
2168 error_msg.contains("has been modified since you last read it"),
2169 "Error should mention file modification, got: {}",
2170 error_msg
2171 );
2172 }
2173
2174 #[gpui::test]
2175 async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2176 init_test(cx);
2177
2178 let fs = project::FakeFs::new(cx.executor());
2179 fs.insert_tree(
2180 "/root",
2181 json!({
2182 "test.txt": "original content"
2183 }),
2184 )
2185 .await;
2186 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2187 let context_server_registry =
2188 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2189 let model = Arc::new(FakeLanguageModel::default());
2190 let thread = cx.new(|cx| {
2191 Thread::new(
2192 project.clone(),
2193 cx.new(|_cx| ProjectContext::default()),
2194 context_server_registry,
2195 Templates::new(),
2196 Some(model.clone()),
2197 cx,
2198 )
2199 });
2200 let languages = project.read_with(cx, |project, _| project.languages().clone());
2201 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2202
2203 let read_tool = Arc::new(crate::ReadFileTool::new(
2204 thread.downgrade(),
2205 project.clone(),
2206 action_log,
2207 ));
2208 let edit_tool = Arc::new(EditFileTool::new(
2209 project.clone(),
2210 thread.downgrade(),
2211 languages,
2212 Templates::new(),
2213 ));
2214
2215 // Read the file first
2216 cx.update(|cx| {
2217 read_tool.clone().run(
2218 crate::ReadFileToolInput {
2219 path: "root/test.txt".to_string(),
2220 start_line: None,
2221 end_line: None,
2222 },
2223 ToolCallEventStream::test().0,
2224 cx,
2225 )
2226 })
2227 .await
2228 .unwrap();
2229
2230 // Open the buffer and make it dirty by editing without saving
2231 let project_path = project
2232 .read_with(cx, |project, cx| {
2233 project.find_project_path("root/test.txt", cx)
2234 })
2235 .expect("Should find project path");
2236 let buffer = project
2237 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2238 .await
2239 .unwrap();
2240
2241 // Make an in-memory edit to the buffer (making it dirty)
2242 buffer.update(cx, |buffer, cx| {
2243 let end_point = buffer.max_point();
2244 buffer.edit([(end_point..end_point, " added text")], None, cx);
2245 });
2246
2247 // Verify buffer is dirty
2248 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2249 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2250
2251 // Try to edit - should fail because buffer has unsaved changes
2252 let result = cx
2253 .update(|cx| {
2254 edit_tool.clone().run(
2255 EditFileToolInput {
2256 display_description: "Edit with dirty buffer".into(),
2257 path: "root/test.txt".into(),
2258 mode: EditFileMode::Edit,
2259 },
2260 ToolCallEventStream::test().0,
2261 cx,
2262 )
2263 })
2264 .await;
2265
2266 assert!(result.is_err(), "Edit should fail when buffer is dirty");
2267 let error_msg = result.unwrap_err().to_string();
2268 assert!(
2269 error_msg.contains("This file has unsaved changes."),
2270 "Error should mention unsaved changes, got: {}",
2271 error_msg
2272 );
2273 assert!(
2274 error_msg.contains("keep or discard"),
2275 "Error should ask whether to keep or discard changes, got: {}",
2276 error_msg
2277 );
2278 // Since save_file and restore_file_from_disk tools aren't added to the thread,
2279 // the error message should ask the user to manually save or revert
2280 assert!(
2281 error_msg.contains("save or revert the file manually"),
2282 "Error should ask user to manually save or revert when tools aren't available, got: {}",
2283 error_msg
2284 );
2285 }
2286}