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
472 .read_with(cx, |buffer, cx| {
473 let settings = language_settings::LanguageSettings::for_buffer(buffer, cx);
474 settings.format_on_save != FormatOnSave::Off
475 });
476
477 let edit_agent_output = output.await?;
478
479 if format_on_save_enabled {
480 action_log.update(cx, |log, cx| {
481 log.buffer_edited(buffer.clone(), cx);
482 });
483
484 let format_task = project.update(cx, |project, cx| {
485 project.format(
486 HashSet::from_iter([buffer.clone()]),
487 LspFormatTarget::Buffers,
488 false, // Don't push to history since the tool did it.
489 FormatTrigger::Save,
490 cx,
491 )
492 });
493 format_task.await.log_err();
494 }
495
496 project
497 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
498 .await?;
499
500 action_log.update(cx, |log, cx| {
501 log.buffer_edited(buffer.clone(), cx);
502 });
503
504 // Update the recorded read time after a successful edit so consecutive edits work
505 if let Some(abs_path) = abs_path.as_ref() {
506 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
507 buffer.file().and_then(|file| file.disk_state().mtime())
508 }) {
509 self.thread.update(cx, |thread, _| {
510 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
511 })?;
512 }
513 }
514
515 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
516 let (new_text, unified_diff) = cx
517 .background_spawn({
518 let new_snapshot = new_snapshot.clone();
519 let old_text = old_text.clone();
520 async move {
521 let new_text = new_snapshot.text();
522 let diff = language::unified_diff(&old_text, &new_text);
523 (new_text, diff)
524 }
525 })
526 .await;
527
528 let input_path = input.path.display();
529 if unified_diff.is_empty() {
530 anyhow::ensure!(
531 !hallucinated_old_text,
532 formatdoc! {"
533 Some edits were produced but none of them could be applied.
534 Read the relevant sections of {input_path} again so that
535 I can perform the requested edits.
536 "}
537 );
538 anyhow::ensure!(
539 ambiguous_ranges.is_empty(),
540 {
541 let line_numbers = ambiguous_ranges
542 .iter()
543 .map(|range| range.start.to_string())
544 .collect::<Vec<_>>()
545 .join(", ");
546 formatdoc! {"
547 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
548 relevant sections of {input_path} again and extend <old_text> so
549 that I can perform the requested edits.
550 "}
551 }
552 );
553 }
554
555 Ok(EditFileToolOutput {
556 input_path: input.path,
557 new_text,
558 old_text,
559 diff: unified_diff,
560 edit_agent_output,
561 })
562 })
563 }
564
565 fn replay(
566 &self,
567 _input: Self::Input,
568 output: Self::Output,
569 event_stream: ToolCallEventStream,
570 cx: &mut App,
571 ) -> Result<()> {
572 event_stream.update_diff(cx.new(|cx| {
573 Diff::finalized(
574 output.input_path.to_string_lossy().into_owned(),
575 Some(output.old_text.to_string()),
576 output.new_text,
577 self.language_registry.clone(),
578 cx,
579 )
580 }));
581 Ok(())
582 }
583
584 fn rebind_thread(
585 &self,
586 new_thread: gpui::WeakEntity<crate::Thread>,
587 ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
588 Some(self.with_thread(new_thread).erase())
589 }
590}
591
592/// Validate that the file path is valid, meaning:
593///
594/// - For `edit` and `overwrite`, the path must point to an existing file.
595/// - For `create`, the file must not already exist, but it's parent dir must exist.
596fn resolve_path(
597 input: &EditFileToolInput,
598 project: Entity<Project>,
599 cx: &mut App,
600) -> Result<ProjectPath> {
601 let project = project.read(cx);
602
603 match input.mode {
604 EditFileMode::Edit | EditFileMode::Overwrite => {
605 let path = project
606 .find_project_path(&input.path, cx)
607 .context("Can't edit file: path not found")?;
608
609 let entry = project
610 .entry_for_path(&path, cx)
611 .context("Can't edit file: path not found")?;
612
613 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
614 Ok(path)
615 }
616
617 EditFileMode::Create => {
618 if let Some(path) = project.find_project_path(&input.path, cx) {
619 anyhow::ensure!(
620 project.entry_for_path(&path, cx).is_none(),
621 "Can't create file: file already exists"
622 );
623 }
624
625 let parent_path = input
626 .path
627 .parent()
628 .context("Can't create file: incorrect path")?;
629
630 let parent_project_path = project.find_project_path(&parent_path, cx);
631
632 let parent_entry = parent_project_path
633 .as_ref()
634 .and_then(|path| project.entry_for_path(path, cx))
635 .context("Can't create file: parent directory doesn't exist")?;
636
637 anyhow::ensure!(
638 parent_entry.is_dir(),
639 "Can't create file: parent is not a directory"
640 );
641
642 let file_name = input
643 .path
644 .file_name()
645 .and_then(|file_name| file_name.to_str())
646 .and_then(|file_name| RelPath::unix(file_name).ok())
647 .context("Can't create file: invalid filename")?;
648
649 let new_file_path = parent_project_path.map(|parent| ProjectPath {
650 path: parent.path.join(file_name),
651 ..parent
652 });
653
654 new_file_path.context("Can't create file")
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::{ContextServerRegistry, Templates};
663 use fs::Fs;
664 use gpui::{TestAppContext, UpdateGlobal};
665 use language_model::fake_provider::FakeLanguageModel;
666 use prompt_store::ProjectContext;
667 use serde_json::json;
668 use settings::SettingsStore;
669 use util::{path, rel_path::rel_path};
670
671 #[gpui::test]
672 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
673 init_test(cx);
674
675 let fs = project::FakeFs::new(cx.executor());
676 fs.insert_tree("/root", json!({})).await;
677 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
678 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
679 let context_server_registry =
680 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
681 let model = Arc::new(FakeLanguageModel::default());
682 let thread = cx.new(|cx| {
683 Thread::new(
684 project.clone(),
685 cx.new(|_cx| ProjectContext::default()),
686 context_server_registry,
687 Templates::new(),
688 Some(model),
689 cx,
690 )
691 });
692 let result = cx
693 .update(|cx| {
694 let input = EditFileToolInput {
695 display_description: "Some edit".into(),
696 path: "root/nonexistent_file.txt".into(),
697 mode: EditFileMode::Edit,
698 };
699 Arc::new(EditFileTool::new(
700 project,
701 thread.downgrade(),
702 language_registry,
703 Templates::new(),
704 ))
705 .run(input, ToolCallEventStream::test().0, cx)
706 })
707 .await;
708 assert_eq!(
709 result.unwrap_err().to_string(),
710 "Can't edit file: path not found"
711 );
712 }
713
714 #[gpui::test]
715 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
716 let mode = &EditFileMode::Create;
717
718 let result = test_resolve_path(mode, "root/new.txt", cx);
719 assert_resolved_path_eq(result.await, rel_path("new.txt"));
720
721 let result = test_resolve_path(mode, "new.txt", cx);
722 assert_resolved_path_eq(result.await, rel_path("new.txt"));
723
724 let result = test_resolve_path(mode, "dir/new.txt", cx);
725 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
726
727 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
728 assert_eq!(
729 result.await.unwrap_err().to_string(),
730 "Can't create file: file already exists"
731 );
732
733 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
734 assert_eq!(
735 result.await.unwrap_err().to_string(),
736 "Can't create file: parent directory doesn't exist"
737 );
738 }
739
740 #[gpui::test]
741 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
742 let mode = &EditFileMode::Edit;
743
744 let path_with_root = "root/dir/subdir/existing.txt";
745 let path_without_root = "dir/subdir/existing.txt";
746 let result = test_resolve_path(mode, path_with_root, cx);
747 assert_resolved_path_eq(result.await, rel_path(path_without_root));
748
749 let result = test_resolve_path(mode, path_without_root, cx);
750 assert_resolved_path_eq(result.await, rel_path(path_without_root));
751
752 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
753 assert_eq!(
754 result.await.unwrap_err().to_string(),
755 "Can't edit file: path not found"
756 );
757
758 let result = test_resolve_path(mode, "root/dir", cx);
759 assert_eq!(
760 result.await.unwrap_err().to_string(),
761 "Can't edit file: path is a directory"
762 );
763 }
764
765 async fn test_resolve_path(
766 mode: &EditFileMode,
767 path: &str,
768 cx: &mut TestAppContext,
769 ) -> anyhow::Result<ProjectPath> {
770 init_test(cx);
771
772 let fs = project::FakeFs::new(cx.executor());
773 fs.insert_tree(
774 "/root",
775 json!({
776 "dir": {
777 "subdir": {
778 "existing.txt": "hello"
779 }
780 }
781 }),
782 )
783 .await;
784 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
785
786 let input = EditFileToolInput {
787 display_description: "Some edit".into(),
788 path: path.into(),
789 mode: mode.clone(),
790 };
791
792 cx.update(|cx| resolve_path(&input, project, cx))
793 }
794
795 #[track_caller]
796 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
797 let actual = path.expect("Should return valid path").path;
798 assert_eq!(actual.as_ref(), expected);
799 }
800
801 #[gpui::test]
802 async fn test_format_on_save(cx: &mut TestAppContext) {
803 init_test(cx);
804
805 let fs = project::FakeFs::new(cx.executor());
806 fs.insert_tree("/root", json!({"src": {}})).await;
807
808 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
809
810 // Set up a Rust language with LSP formatting support
811 let rust_language = Arc::new(language::Language::new(
812 language::LanguageConfig {
813 name: "Rust".into(),
814 matcher: language::LanguageMatcher {
815 path_suffixes: vec!["rs".to_string()],
816 ..Default::default()
817 },
818 ..Default::default()
819 },
820 None,
821 ));
822
823 // Register the language and fake LSP
824 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
825 language_registry.add(rust_language);
826
827 let mut fake_language_servers = language_registry.register_fake_lsp(
828 "Rust",
829 language::FakeLspAdapter {
830 capabilities: lsp::ServerCapabilities {
831 document_formatting_provider: Some(lsp::OneOf::Left(true)),
832 ..Default::default()
833 },
834 ..Default::default()
835 },
836 );
837
838 // Create the file
839 fs.save(
840 path!("/root/src/main.rs").as_ref(),
841 &"initial content".into(),
842 language::LineEnding::Unix,
843 )
844 .await
845 .unwrap();
846
847 // Open the buffer to trigger LSP initialization
848 let buffer = project
849 .update(cx, |project, cx| {
850 project.open_local_buffer(path!("/root/src/main.rs"), cx)
851 })
852 .await
853 .unwrap();
854
855 // Register the buffer with language servers
856 let _handle = project.update(cx, |project, cx| {
857 project.register_buffer_with_language_servers(&buffer, cx)
858 });
859
860 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
861 const FORMATTED_CONTENT: &str =
862 "This file was formatted by the fake formatter in the test.\n";
863
864 // Get the fake language server and set up formatting handler
865 let fake_language_server = fake_language_servers.next().await.unwrap();
866 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
867 |_, _| async move {
868 Ok(Some(vec![lsp::TextEdit {
869 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
870 new_text: FORMATTED_CONTENT.to_string(),
871 }]))
872 }
873 });
874
875 let context_server_registry =
876 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
877 let model = Arc::new(FakeLanguageModel::default());
878 let thread = cx.new(|cx| {
879 Thread::new(
880 project.clone(),
881 cx.new(|_cx| ProjectContext::default()),
882 context_server_registry,
883 Templates::new(),
884 Some(model.clone()),
885 cx,
886 )
887 });
888
889 // First, test with format_on_save enabled
890 cx.update(|cx| {
891 SettingsStore::update_global(cx, |store, cx| {
892 store.update_user_settings(cx, |settings| {
893 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
894 settings.project.all_languages.defaults.formatter =
895 Some(language::language_settings::FormatterList::default());
896 });
897 });
898 });
899
900 // Have the model stream unformatted content
901 let edit_result = {
902 let edit_task = cx.update(|cx| {
903 let input = EditFileToolInput {
904 display_description: "Create main function".into(),
905 path: "root/src/main.rs".into(),
906 mode: EditFileMode::Overwrite,
907 };
908 Arc::new(EditFileTool::new(
909 project.clone(),
910 thread.downgrade(),
911 language_registry.clone(),
912 Templates::new(),
913 ))
914 .run(input, ToolCallEventStream::test().0, cx)
915 });
916
917 // Stream the unformatted content
918 cx.executor().run_until_parked();
919 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
920 model.end_last_completion_stream();
921
922 edit_task.await
923 };
924 assert!(edit_result.is_ok());
925
926 // Wait for any async operations (e.g. formatting) to complete
927 cx.executor().run_until_parked();
928
929 // Read the file to verify it was formatted automatically
930 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
931 assert_eq!(
932 // Ignore carriage returns on Windows
933 new_content.replace("\r\n", "\n"),
934 FORMATTED_CONTENT,
935 "Code should be formatted when format_on_save is enabled"
936 );
937
938 let stale_buffer_count = thread
939 .read_with(cx, |thread, _cx| thread.action_log.clone())
940 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
941
942 assert_eq!(
943 stale_buffer_count, 0,
944 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
945 This causes the agent to think the file was modified externally when it was just formatted.",
946 stale_buffer_count
947 );
948
949 // Next, test with format_on_save disabled
950 cx.update(|cx| {
951 SettingsStore::update_global(cx, |store, cx| {
952 store.update_user_settings(cx, |settings| {
953 settings.project.all_languages.defaults.format_on_save =
954 Some(FormatOnSave::Off);
955 });
956 });
957 });
958
959 // Stream unformatted edits again
960 let edit_result = {
961 let edit_task = cx.update(|cx| {
962 let input = EditFileToolInput {
963 display_description: "Update main function".into(),
964 path: "root/src/main.rs".into(),
965 mode: EditFileMode::Overwrite,
966 };
967 Arc::new(EditFileTool::new(
968 project.clone(),
969 thread.downgrade(),
970 language_registry,
971 Templates::new(),
972 ))
973 .run(input, ToolCallEventStream::test().0, cx)
974 });
975
976 // Stream the unformatted content
977 cx.executor().run_until_parked();
978 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
979 model.end_last_completion_stream();
980
981 edit_task.await
982 };
983 assert!(edit_result.is_ok());
984
985 // Wait for any async operations (e.g. formatting) to complete
986 cx.executor().run_until_parked();
987
988 // Verify the file was not formatted
989 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
990 assert_eq!(
991 // Ignore carriage returns on Windows
992 new_content.replace("\r\n", "\n"),
993 UNFORMATTED_CONTENT,
994 "Code should not be formatted when format_on_save is disabled"
995 );
996 }
997
998 #[gpui::test]
999 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1000 init_test(cx);
1001
1002 let fs = project::FakeFs::new(cx.executor());
1003 fs.insert_tree("/root", json!({"src": {}})).await;
1004
1005 // Create a simple file with trailing whitespace
1006 fs.save(
1007 path!("/root/src/main.rs").as_ref(),
1008 &"initial content".into(),
1009 language::LineEnding::Unix,
1010 )
1011 .await
1012 .unwrap();
1013
1014 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1015 let context_server_registry =
1016 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1017 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1018 let model = Arc::new(FakeLanguageModel::default());
1019 let thread = cx.new(|cx| {
1020 Thread::new(
1021 project.clone(),
1022 cx.new(|_cx| ProjectContext::default()),
1023 context_server_registry,
1024 Templates::new(),
1025 Some(model.clone()),
1026 cx,
1027 )
1028 });
1029
1030 // First, test with remove_trailing_whitespace_on_save enabled
1031 cx.update(|cx| {
1032 SettingsStore::update_global(cx, |store, cx| {
1033 store.update_user_settings(cx, |settings| {
1034 settings
1035 .project
1036 .all_languages
1037 .defaults
1038 .remove_trailing_whitespace_on_save = Some(true);
1039 });
1040 });
1041 });
1042
1043 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1044 "fn main() { \n println!(\"Hello!\"); \n}\n";
1045
1046 // Have the model stream content that contains trailing whitespace
1047 let edit_result = {
1048 let edit_task = cx.update(|cx| {
1049 let input = EditFileToolInput {
1050 display_description: "Create main function".into(),
1051 path: "root/src/main.rs".into(),
1052 mode: EditFileMode::Overwrite,
1053 };
1054 Arc::new(EditFileTool::new(
1055 project.clone(),
1056 thread.downgrade(),
1057 language_registry.clone(),
1058 Templates::new(),
1059 ))
1060 .run(input, ToolCallEventStream::test().0, cx)
1061 });
1062
1063 // Stream the content with trailing whitespace
1064 cx.executor().run_until_parked();
1065 model.send_last_completion_stream_text_chunk(
1066 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1067 );
1068 model.end_last_completion_stream();
1069
1070 edit_task.await
1071 };
1072 assert!(edit_result.is_ok());
1073
1074 // Wait for any async operations (e.g. formatting) to complete
1075 cx.executor().run_until_parked();
1076
1077 // Read the file to verify trailing whitespace was removed automatically
1078 assert_eq!(
1079 // Ignore carriage returns on Windows
1080 fs.load(path!("/root/src/main.rs").as_ref())
1081 .await
1082 .unwrap()
1083 .replace("\r\n", "\n"),
1084 "fn main() {\n println!(\"Hello!\");\n}\n",
1085 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1086 );
1087
1088 // Next, test with remove_trailing_whitespace_on_save disabled
1089 cx.update(|cx| {
1090 SettingsStore::update_global(cx, |store, cx| {
1091 store.update_user_settings(cx, |settings| {
1092 settings
1093 .project
1094 .all_languages
1095 .defaults
1096 .remove_trailing_whitespace_on_save = Some(false);
1097 });
1098 });
1099 });
1100
1101 // Stream edits again with trailing whitespace
1102 let edit_result = {
1103 let edit_task = cx.update(|cx| {
1104 let input = EditFileToolInput {
1105 display_description: "Update main function".into(),
1106 path: "root/src/main.rs".into(),
1107 mode: EditFileMode::Overwrite,
1108 };
1109 Arc::new(EditFileTool::new(
1110 project.clone(),
1111 thread.downgrade(),
1112 language_registry,
1113 Templates::new(),
1114 ))
1115 .run(input, ToolCallEventStream::test().0, cx)
1116 });
1117
1118 // Stream the content with trailing whitespace
1119 cx.executor().run_until_parked();
1120 model.send_last_completion_stream_text_chunk(
1121 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1122 );
1123 model.end_last_completion_stream();
1124
1125 edit_task.await
1126 };
1127 assert!(edit_result.is_ok());
1128
1129 // Wait for any async operations (e.g. formatting) to complete
1130 cx.executor().run_until_parked();
1131
1132 // Verify the file still has trailing whitespace
1133 // Read the file again - it should still have trailing whitespace
1134 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1135 assert_eq!(
1136 // Ignore carriage returns on Windows
1137 final_content.replace("\r\n", "\n"),
1138 CONTENT_WITH_TRAILING_WHITESPACE,
1139 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1140 );
1141 }
1142
1143 #[gpui::test]
1144 async fn test_authorize(cx: &mut TestAppContext) {
1145 init_test(cx);
1146 let fs = project::FakeFs::new(cx.executor());
1147 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1148 let context_server_registry =
1149 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1150 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1151 let model = Arc::new(FakeLanguageModel::default());
1152 let thread = cx.new(|cx| {
1153 Thread::new(
1154 project.clone(),
1155 cx.new(|_cx| ProjectContext::default()),
1156 context_server_registry,
1157 Templates::new(),
1158 Some(model.clone()),
1159 cx,
1160 )
1161 });
1162 let tool = Arc::new(EditFileTool::new(
1163 project.clone(),
1164 thread.downgrade(),
1165 language_registry,
1166 Templates::new(),
1167 ));
1168 fs.insert_tree("/root", json!({})).await;
1169
1170 // Test 1: Path with .zed component should require confirmation
1171 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1172 let _auth = cx.update(|cx| {
1173 tool.authorize(
1174 &EditFileToolInput {
1175 display_description: "test 1".into(),
1176 path: ".zed/settings.json".into(),
1177 mode: EditFileMode::Edit,
1178 },
1179 &stream_tx,
1180 cx,
1181 )
1182 });
1183
1184 let event = stream_rx.expect_authorization().await;
1185 assert_eq!(
1186 event.tool_call.fields.title,
1187 Some("test 1 (local settings)".into())
1188 );
1189
1190 // Test 2: Path outside project should require confirmation
1191 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1192 let _auth = cx.update(|cx| {
1193 tool.authorize(
1194 &EditFileToolInput {
1195 display_description: "test 2".into(),
1196 path: "/etc/hosts".into(),
1197 mode: EditFileMode::Edit,
1198 },
1199 &stream_tx,
1200 cx,
1201 )
1202 });
1203
1204 let event = stream_rx.expect_authorization().await;
1205 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1206
1207 // Test 3: Relative path without .zed should not require confirmation
1208 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1209 cx.update(|cx| {
1210 tool.authorize(
1211 &EditFileToolInput {
1212 display_description: "test 3".into(),
1213 path: "root/src/main.rs".into(),
1214 mode: EditFileMode::Edit,
1215 },
1216 &stream_tx,
1217 cx,
1218 )
1219 })
1220 .await
1221 .unwrap();
1222 assert!(stream_rx.try_next().is_err());
1223
1224 // Test 4: Path with .zed in the middle should require confirmation
1225 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1226 let _auth = cx.update(|cx| {
1227 tool.authorize(
1228 &EditFileToolInput {
1229 display_description: "test 4".into(),
1230 path: "root/.zed/tasks.json".into(),
1231 mode: EditFileMode::Edit,
1232 },
1233 &stream_tx,
1234 cx,
1235 )
1236 });
1237 let event = stream_rx.expect_authorization().await;
1238 assert_eq!(
1239 event.tool_call.fields.title,
1240 Some("test 4 (local settings)".into())
1241 );
1242
1243 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1244 cx.update(|cx| {
1245 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1246 settings.always_allow_tool_actions = true;
1247 agent_settings::AgentSettings::override_global(settings, cx);
1248 });
1249
1250 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1251 cx.update(|cx| {
1252 tool.authorize(
1253 &EditFileToolInput {
1254 display_description: "test 5.1".into(),
1255 path: ".zed/settings.json".into(),
1256 mode: EditFileMode::Edit,
1257 },
1258 &stream_tx,
1259 cx,
1260 )
1261 })
1262 .await
1263 .unwrap();
1264 assert!(stream_rx.try_next().is_err());
1265
1266 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1267 cx.update(|cx| {
1268 tool.authorize(
1269 &EditFileToolInput {
1270 display_description: "test 5.2".into(),
1271 path: "/etc/hosts".into(),
1272 mode: EditFileMode::Edit,
1273 },
1274 &stream_tx,
1275 cx,
1276 )
1277 })
1278 .await
1279 .unwrap();
1280 assert!(stream_rx.try_next().is_err());
1281 }
1282
1283 #[gpui::test]
1284 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1285 init_test(cx);
1286 let fs = project::FakeFs::new(cx.executor());
1287 fs.insert_tree("/project", json!({})).await;
1288 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1289 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1290 let context_server_registry =
1291 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1292 let model = Arc::new(FakeLanguageModel::default());
1293 let thread = cx.new(|cx| {
1294 Thread::new(
1295 project.clone(),
1296 cx.new(|_cx| ProjectContext::default()),
1297 context_server_registry,
1298 Templates::new(),
1299 Some(model.clone()),
1300 cx,
1301 )
1302 });
1303 let tool = Arc::new(EditFileTool::new(
1304 project.clone(),
1305 thread.downgrade(),
1306 language_registry,
1307 Templates::new(),
1308 ));
1309
1310 // Test global config paths - these should require confirmation if they exist and are outside the project
1311 let test_cases = vec![
1312 (
1313 "/etc/hosts",
1314 true,
1315 "System file should require confirmation",
1316 ),
1317 (
1318 "/usr/local/bin/script",
1319 true,
1320 "System bin file should require confirmation",
1321 ),
1322 (
1323 "project/normal_file.rs",
1324 false,
1325 "Normal project file should not require confirmation",
1326 ),
1327 ];
1328
1329 for (path, should_confirm, description) in test_cases {
1330 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1331 let auth = cx.update(|cx| {
1332 tool.authorize(
1333 &EditFileToolInput {
1334 display_description: "Edit file".into(),
1335 path: path.into(),
1336 mode: EditFileMode::Edit,
1337 },
1338 &stream_tx,
1339 cx,
1340 )
1341 });
1342
1343 if should_confirm {
1344 stream_rx.expect_authorization().await;
1345 } else {
1346 auth.await.unwrap();
1347 assert!(
1348 stream_rx.try_next().is_err(),
1349 "Failed for case: {} - path: {} - expected no confirmation but got one",
1350 description,
1351 path
1352 );
1353 }
1354 }
1355 }
1356
1357 #[gpui::test]
1358 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1359 init_test(cx);
1360 let fs = project::FakeFs::new(cx.executor());
1361
1362 // Create multiple worktree directories
1363 fs.insert_tree(
1364 "/workspace/frontend",
1365 json!({
1366 "src": {
1367 "main.js": "console.log('frontend');"
1368 }
1369 }),
1370 )
1371 .await;
1372 fs.insert_tree(
1373 "/workspace/backend",
1374 json!({
1375 "src": {
1376 "main.rs": "fn main() {}"
1377 }
1378 }),
1379 )
1380 .await;
1381 fs.insert_tree(
1382 "/workspace/shared",
1383 json!({
1384 ".zed": {
1385 "settings.json": "{}"
1386 }
1387 }),
1388 )
1389 .await;
1390
1391 // Create project with multiple worktrees
1392 let project = Project::test(
1393 fs.clone(),
1394 [
1395 path!("/workspace/frontend").as_ref(),
1396 path!("/workspace/backend").as_ref(),
1397 path!("/workspace/shared").as_ref(),
1398 ],
1399 cx,
1400 )
1401 .await;
1402 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1403 let context_server_registry =
1404 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1405 let model = Arc::new(FakeLanguageModel::default());
1406 let thread = cx.new(|cx| {
1407 Thread::new(
1408 project.clone(),
1409 cx.new(|_cx| ProjectContext::default()),
1410 context_server_registry.clone(),
1411 Templates::new(),
1412 Some(model.clone()),
1413 cx,
1414 )
1415 });
1416 let tool = Arc::new(EditFileTool::new(
1417 project.clone(),
1418 thread.downgrade(),
1419 language_registry,
1420 Templates::new(),
1421 ));
1422
1423 // Test files in different worktrees
1424 let test_cases = vec![
1425 ("frontend/src/main.js", false, "File in first worktree"),
1426 ("backend/src/main.rs", false, "File in second worktree"),
1427 (
1428 "shared/.zed/settings.json",
1429 true,
1430 ".zed file in third worktree",
1431 ),
1432 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1433 (
1434 "../outside/file.txt",
1435 true,
1436 "Relative path outside worktrees",
1437 ),
1438 ];
1439
1440 for (path, should_confirm, description) in test_cases {
1441 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1442 let auth = cx.update(|cx| {
1443 tool.authorize(
1444 &EditFileToolInput {
1445 display_description: "Edit file".into(),
1446 path: path.into(),
1447 mode: EditFileMode::Edit,
1448 },
1449 &stream_tx,
1450 cx,
1451 )
1452 });
1453
1454 if should_confirm {
1455 stream_rx.expect_authorization().await;
1456 } else {
1457 auth.await.unwrap();
1458 assert!(
1459 stream_rx.try_next().is_err(),
1460 "Failed for case: {} - path: {} - expected no confirmation but got one",
1461 description,
1462 path
1463 );
1464 }
1465 }
1466 }
1467
1468 #[gpui::test]
1469 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1470 init_test(cx);
1471 let fs = project::FakeFs::new(cx.executor());
1472 fs.insert_tree(
1473 "/project",
1474 json!({
1475 ".zed": {
1476 "settings.json": "{}"
1477 },
1478 "src": {
1479 ".zed": {
1480 "local.json": "{}"
1481 }
1482 }
1483 }),
1484 )
1485 .await;
1486 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1487 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1488 let context_server_registry =
1489 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1490 let model = Arc::new(FakeLanguageModel::default());
1491 let thread = cx.new(|cx| {
1492 Thread::new(
1493 project.clone(),
1494 cx.new(|_cx| ProjectContext::default()),
1495 context_server_registry.clone(),
1496 Templates::new(),
1497 Some(model.clone()),
1498 cx,
1499 )
1500 });
1501 let tool = Arc::new(EditFileTool::new(
1502 project.clone(),
1503 thread.downgrade(),
1504 language_registry,
1505 Templates::new(),
1506 ));
1507
1508 // Test edge cases
1509 let test_cases = vec![
1510 // Empty path - find_project_path returns Some for empty paths
1511 ("", false, "Empty path is treated as project root"),
1512 // Root directory
1513 ("/", true, "Root directory should be outside project"),
1514 // Parent directory references - find_project_path resolves these
1515 (
1516 "project/../other",
1517 true,
1518 "Path with .. that goes outside of root directory",
1519 ),
1520 (
1521 "project/./src/file.rs",
1522 false,
1523 "Path with . should work normally",
1524 ),
1525 // Windows-style paths (if on Windows)
1526 #[cfg(target_os = "windows")]
1527 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1528 #[cfg(target_os = "windows")]
1529 ("project\\src\\main.rs", false, "Windows-style project path"),
1530 ];
1531
1532 for (path, should_confirm, description) in test_cases {
1533 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1534 let auth = cx.update(|cx| {
1535 tool.authorize(
1536 &EditFileToolInput {
1537 display_description: "Edit file".into(),
1538 path: path.into(),
1539 mode: EditFileMode::Edit,
1540 },
1541 &stream_tx,
1542 cx,
1543 )
1544 });
1545
1546 cx.run_until_parked();
1547
1548 if should_confirm {
1549 stream_rx.expect_authorization().await;
1550 } else {
1551 assert!(
1552 stream_rx.try_next().is_err(),
1553 "Failed for case: {} - path: {} - expected no confirmation but got one",
1554 description,
1555 path
1556 );
1557 auth.await.unwrap();
1558 }
1559 }
1560 }
1561
1562 #[gpui::test]
1563 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1564 init_test(cx);
1565 let fs = project::FakeFs::new(cx.executor());
1566 fs.insert_tree(
1567 "/project",
1568 json!({
1569 "existing.txt": "content",
1570 ".zed": {
1571 "settings.json": "{}"
1572 }
1573 }),
1574 )
1575 .await;
1576 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1577 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1578 let context_server_registry =
1579 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1580 let model = Arc::new(FakeLanguageModel::default());
1581 let thread = cx.new(|cx| {
1582 Thread::new(
1583 project.clone(),
1584 cx.new(|_cx| ProjectContext::default()),
1585 context_server_registry.clone(),
1586 Templates::new(),
1587 Some(model.clone()),
1588 cx,
1589 )
1590 });
1591 let tool = Arc::new(EditFileTool::new(
1592 project.clone(),
1593 thread.downgrade(),
1594 language_registry,
1595 Templates::new(),
1596 ));
1597
1598 // Test different EditFileMode values
1599 let modes = vec![
1600 EditFileMode::Edit,
1601 EditFileMode::Create,
1602 EditFileMode::Overwrite,
1603 ];
1604
1605 for mode in modes {
1606 // Test .zed path with different modes
1607 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1608 let _auth = cx.update(|cx| {
1609 tool.authorize(
1610 &EditFileToolInput {
1611 display_description: "Edit settings".into(),
1612 path: "project/.zed/settings.json".into(),
1613 mode: mode.clone(),
1614 },
1615 &stream_tx,
1616 cx,
1617 )
1618 });
1619
1620 stream_rx.expect_authorization().await;
1621
1622 // Test outside path with different modes
1623 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1624 let _auth = cx.update(|cx| {
1625 tool.authorize(
1626 &EditFileToolInput {
1627 display_description: "Edit file".into(),
1628 path: "/outside/file.txt".into(),
1629 mode: mode.clone(),
1630 },
1631 &stream_tx,
1632 cx,
1633 )
1634 });
1635
1636 stream_rx.expect_authorization().await;
1637
1638 // Test normal path with different modes
1639 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1640 cx.update(|cx| {
1641 tool.authorize(
1642 &EditFileToolInput {
1643 display_description: "Edit file".into(),
1644 path: "project/normal.txt".into(),
1645 mode: mode.clone(),
1646 },
1647 &stream_tx,
1648 cx,
1649 )
1650 })
1651 .await
1652 .unwrap();
1653 assert!(stream_rx.try_next().is_err());
1654 }
1655 }
1656
1657 #[gpui::test]
1658 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1659 init_test(cx);
1660 let fs = project::FakeFs::new(cx.executor());
1661 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1662 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1663 let context_server_registry =
1664 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1665 let model = Arc::new(FakeLanguageModel::default());
1666 let thread = cx.new(|cx| {
1667 Thread::new(
1668 project.clone(),
1669 cx.new(|_cx| ProjectContext::default()),
1670 context_server_registry,
1671 Templates::new(),
1672 Some(model.clone()),
1673 cx,
1674 )
1675 });
1676 let tool = Arc::new(EditFileTool::new(
1677 project,
1678 thread.downgrade(),
1679 language_registry,
1680 Templates::new(),
1681 ));
1682
1683 cx.update(|cx| {
1684 // ...
1685 assert_eq!(
1686 tool.initial_title(
1687 Err(json!({
1688 "path": "src/main.rs",
1689 "display_description": "",
1690 "old_string": "old code",
1691 "new_string": "new code"
1692 })),
1693 cx
1694 ),
1695 "src/main.rs"
1696 );
1697 assert_eq!(
1698 tool.initial_title(
1699 Err(json!({
1700 "path": "",
1701 "display_description": "Fix error handling",
1702 "old_string": "old code",
1703 "new_string": "new code"
1704 })),
1705 cx
1706 ),
1707 "Fix error handling"
1708 );
1709 assert_eq!(
1710 tool.initial_title(
1711 Err(json!({
1712 "path": "src/main.rs",
1713 "display_description": "Fix error handling",
1714 "old_string": "old code",
1715 "new_string": "new code"
1716 })),
1717 cx
1718 ),
1719 "src/main.rs"
1720 );
1721 assert_eq!(
1722 tool.initial_title(
1723 Err(json!({
1724 "path": "",
1725 "display_description": "",
1726 "old_string": "old code",
1727 "new_string": "new code"
1728 })),
1729 cx
1730 ),
1731 DEFAULT_UI_TEXT
1732 );
1733 assert_eq!(
1734 tool.initial_title(Err(serde_json::Value::Null), cx),
1735 DEFAULT_UI_TEXT
1736 );
1737 });
1738 }
1739
1740 #[gpui::test]
1741 async fn test_diff_finalization(cx: &mut TestAppContext) {
1742 init_test(cx);
1743 let fs = project::FakeFs::new(cx.executor());
1744 fs.insert_tree("/", json!({"main.rs": ""})).await;
1745
1746 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1747 let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1748 let context_server_registry =
1749 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1750 let model = Arc::new(FakeLanguageModel::default());
1751 let thread = cx.new(|cx| {
1752 Thread::new(
1753 project.clone(),
1754 cx.new(|_cx| ProjectContext::default()),
1755 context_server_registry.clone(),
1756 Templates::new(),
1757 Some(model.clone()),
1758 cx,
1759 )
1760 });
1761
1762 // Ensure the diff is finalized after the edit completes.
1763 {
1764 let tool = Arc::new(EditFileTool::new(
1765 project.clone(),
1766 thread.downgrade(),
1767 languages.clone(),
1768 Templates::new(),
1769 ));
1770 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1771 let edit = cx.update(|cx| {
1772 tool.run(
1773 EditFileToolInput {
1774 display_description: "Edit file".into(),
1775 path: path!("/main.rs").into(),
1776 mode: EditFileMode::Edit,
1777 },
1778 stream_tx,
1779 cx,
1780 )
1781 });
1782 stream_rx.expect_update_fields().await;
1783 let diff = stream_rx.expect_diff().await;
1784 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1785 cx.run_until_parked();
1786 model.end_last_completion_stream();
1787 edit.await.unwrap();
1788 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1789 }
1790
1791 // Ensure the diff is finalized if an error occurs while editing.
1792 {
1793 model.forbid_requests();
1794 let tool = Arc::new(EditFileTool::new(
1795 project.clone(),
1796 thread.downgrade(),
1797 languages.clone(),
1798 Templates::new(),
1799 ));
1800 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1801 let edit = cx.update(|cx| {
1802 tool.run(
1803 EditFileToolInput {
1804 display_description: "Edit file".into(),
1805 path: path!("/main.rs").into(),
1806 mode: EditFileMode::Edit,
1807 },
1808 stream_tx,
1809 cx,
1810 )
1811 });
1812 stream_rx.expect_update_fields().await;
1813 let diff = stream_rx.expect_diff().await;
1814 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1815 edit.await.unwrap_err();
1816 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1817 model.allow_requests();
1818 }
1819
1820 // Ensure the diff is finalized if the tool call gets dropped.
1821 {
1822 let tool = Arc::new(EditFileTool::new(
1823 project.clone(),
1824 thread.downgrade(),
1825 languages.clone(),
1826 Templates::new(),
1827 ));
1828 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1829 let edit = cx.update(|cx| {
1830 tool.run(
1831 EditFileToolInput {
1832 display_description: "Edit file".into(),
1833 path: path!("/main.rs").into(),
1834 mode: EditFileMode::Edit,
1835 },
1836 stream_tx,
1837 cx,
1838 )
1839 });
1840 stream_rx.expect_update_fields().await;
1841 let diff = stream_rx.expect_diff().await;
1842 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1843 drop(edit);
1844 cx.run_until_parked();
1845 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1846 }
1847 }
1848
1849 #[gpui::test]
1850 async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1851 init_test(cx);
1852
1853 let fs = project::FakeFs::new(cx.executor());
1854 fs.insert_tree(
1855 "/root",
1856 json!({
1857 "test.txt": "original content"
1858 }),
1859 )
1860 .await;
1861 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1862 let context_server_registry =
1863 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1864 let model = Arc::new(FakeLanguageModel::default());
1865 let thread = cx.new(|cx| {
1866 Thread::new(
1867 project.clone(),
1868 cx.new(|_cx| ProjectContext::default()),
1869 context_server_registry,
1870 Templates::new(),
1871 Some(model.clone()),
1872 cx,
1873 )
1874 });
1875 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1876
1877 // Initially, file_read_times should be empty
1878 let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1879 assert!(is_empty, "file_read_times should start empty");
1880
1881 // Create read tool
1882 let read_tool = Arc::new(crate::ReadFileTool::new(
1883 thread.downgrade(),
1884 project.clone(),
1885 action_log,
1886 ));
1887
1888 // Read the file to record the read time
1889 cx.update(|cx| {
1890 read_tool.clone().run(
1891 crate::ReadFileToolInput {
1892 path: "root/test.txt".to_string(),
1893 start_line: None,
1894 end_line: None,
1895 },
1896 ToolCallEventStream::test().0,
1897 cx,
1898 )
1899 })
1900 .await
1901 .unwrap();
1902
1903 // Verify that file_read_times now contains an entry for the file
1904 let has_entry = thread.read_with(cx, |thread, _| {
1905 thread.file_read_times.len() == 1
1906 && thread
1907 .file_read_times
1908 .keys()
1909 .any(|path| path.ends_with("test.txt"))
1910 });
1911 assert!(
1912 has_entry,
1913 "file_read_times should contain an entry after reading the file"
1914 );
1915
1916 // Read the file again - should update the entry
1917 cx.update(|cx| {
1918 read_tool.clone().run(
1919 crate::ReadFileToolInput {
1920 path: "root/test.txt".to_string(),
1921 start_line: None,
1922 end_line: None,
1923 },
1924 ToolCallEventStream::test().0,
1925 cx,
1926 )
1927 })
1928 .await
1929 .unwrap();
1930
1931 // Should still have exactly one entry
1932 let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
1933 assert!(
1934 has_one_entry,
1935 "file_read_times should still have one entry after re-reading"
1936 );
1937 }
1938
1939 fn init_test(cx: &mut TestAppContext) {
1940 cx.update(|cx| {
1941 let settings_store = SettingsStore::test(cx);
1942 cx.set_global(settings_store);
1943 });
1944 }
1945
1946 #[gpui::test]
1947 async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
1948 init_test(cx);
1949
1950 let fs = project::FakeFs::new(cx.executor());
1951 fs.insert_tree(
1952 "/root",
1953 json!({
1954 "test.txt": "original content"
1955 }),
1956 )
1957 .await;
1958 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1959 let context_server_registry =
1960 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1961 let model = Arc::new(FakeLanguageModel::default());
1962 let thread = cx.new(|cx| {
1963 Thread::new(
1964 project.clone(),
1965 cx.new(|_cx| ProjectContext::default()),
1966 context_server_registry,
1967 Templates::new(),
1968 Some(model.clone()),
1969 cx,
1970 )
1971 });
1972 let languages = project.read_with(cx, |project, _| project.languages().clone());
1973 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1974
1975 let read_tool = Arc::new(crate::ReadFileTool::new(
1976 thread.downgrade(),
1977 project.clone(),
1978 action_log,
1979 ));
1980 let edit_tool = Arc::new(EditFileTool::new(
1981 project.clone(),
1982 thread.downgrade(),
1983 languages,
1984 Templates::new(),
1985 ));
1986
1987 // Read the file first
1988 cx.update(|cx| {
1989 read_tool.clone().run(
1990 crate::ReadFileToolInput {
1991 path: "root/test.txt".to_string(),
1992 start_line: None,
1993 end_line: None,
1994 },
1995 ToolCallEventStream::test().0,
1996 cx,
1997 )
1998 })
1999 .await
2000 .unwrap();
2001
2002 // First edit should work
2003 let edit_result = {
2004 let edit_task = cx.update(|cx| {
2005 edit_tool.clone().run(
2006 EditFileToolInput {
2007 display_description: "First edit".into(),
2008 path: "root/test.txt".into(),
2009 mode: EditFileMode::Edit,
2010 },
2011 ToolCallEventStream::test().0,
2012 cx,
2013 )
2014 });
2015
2016 cx.executor().run_until_parked();
2017 model.send_last_completion_stream_text_chunk(
2018 "<old_text>original content</old_text><new_text>modified content</new_text>"
2019 .to_string(),
2020 );
2021 model.end_last_completion_stream();
2022
2023 edit_task.await
2024 };
2025 assert!(
2026 edit_result.is_ok(),
2027 "First edit should succeed, got error: {:?}",
2028 edit_result.as_ref().err()
2029 );
2030
2031 // Second edit should also work because the edit updated the recorded read time
2032 let edit_result = {
2033 let edit_task = cx.update(|cx| {
2034 edit_tool.clone().run(
2035 EditFileToolInput {
2036 display_description: "Second edit".into(),
2037 path: "root/test.txt".into(),
2038 mode: EditFileMode::Edit,
2039 },
2040 ToolCallEventStream::test().0,
2041 cx,
2042 )
2043 });
2044
2045 cx.executor().run_until_parked();
2046 model.send_last_completion_stream_text_chunk(
2047 "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2048 );
2049 model.end_last_completion_stream();
2050
2051 edit_task.await
2052 };
2053 assert!(
2054 edit_result.is_ok(),
2055 "Second consecutive edit should succeed, got error: {:?}",
2056 edit_result.as_ref().err()
2057 );
2058 }
2059
2060 #[gpui::test]
2061 async fn test_external_modification_detected(cx: &mut TestAppContext) {
2062 init_test(cx);
2063
2064 let fs = project::FakeFs::new(cx.executor());
2065 fs.insert_tree(
2066 "/root",
2067 json!({
2068 "test.txt": "original content"
2069 }),
2070 )
2071 .await;
2072 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2073 let context_server_registry =
2074 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2075 let model = Arc::new(FakeLanguageModel::default());
2076 let thread = cx.new(|cx| {
2077 Thread::new(
2078 project.clone(),
2079 cx.new(|_cx| ProjectContext::default()),
2080 context_server_registry,
2081 Templates::new(),
2082 Some(model.clone()),
2083 cx,
2084 )
2085 });
2086 let languages = project.read_with(cx, |project, _| project.languages().clone());
2087 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2088
2089 let read_tool = Arc::new(crate::ReadFileTool::new(
2090 thread.downgrade(),
2091 project.clone(),
2092 action_log,
2093 ));
2094 let edit_tool = Arc::new(EditFileTool::new(
2095 project.clone(),
2096 thread.downgrade(),
2097 languages,
2098 Templates::new(),
2099 ));
2100
2101 // Read the file first
2102 cx.update(|cx| {
2103 read_tool.clone().run(
2104 crate::ReadFileToolInput {
2105 path: "root/test.txt".to_string(),
2106 start_line: None,
2107 end_line: None,
2108 },
2109 ToolCallEventStream::test().0,
2110 cx,
2111 )
2112 })
2113 .await
2114 .unwrap();
2115
2116 // Simulate external modification - advance time and save file
2117 cx.background_executor
2118 .advance_clock(std::time::Duration::from_secs(2));
2119 fs.save(
2120 path!("/root/test.txt").as_ref(),
2121 &"externally modified content".into(),
2122 language::LineEnding::Unix,
2123 )
2124 .await
2125 .unwrap();
2126
2127 // Reload the buffer to pick up the new mtime
2128 let project_path = project
2129 .read_with(cx, |project, cx| {
2130 project.find_project_path("root/test.txt", cx)
2131 })
2132 .expect("Should find project path");
2133 let buffer = project
2134 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2135 .await
2136 .unwrap();
2137 buffer
2138 .update(cx, |buffer, cx| buffer.reload(cx))
2139 .await
2140 .unwrap();
2141
2142 cx.executor().run_until_parked();
2143
2144 // Try to edit - should fail because file was modified externally
2145 let result = cx
2146 .update(|cx| {
2147 edit_tool.clone().run(
2148 EditFileToolInput {
2149 display_description: "Edit after external change".into(),
2150 path: "root/test.txt".into(),
2151 mode: EditFileMode::Edit,
2152 },
2153 ToolCallEventStream::test().0,
2154 cx,
2155 )
2156 })
2157 .await;
2158
2159 assert!(
2160 result.is_err(),
2161 "Edit should fail after external modification"
2162 );
2163 let error_msg = result.unwrap_err().to_string();
2164 assert!(
2165 error_msg.contains("has been modified since you last read it"),
2166 "Error should mention file modification, got: {}",
2167 error_msg
2168 );
2169 }
2170
2171 #[gpui::test]
2172 async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2173 init_test(cx);
2174
2175 let fs = project::FakeFs::new(cx.executor());
2176 fs.insert_tree(
2177 "/root",
2178 json!({
2179 "test.txt": "original content"
2180 }),
2181 )
2182 .await;
2183 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2184 let context_server_registry =
2185 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2186 let model = Arc::new(FakeLanguageModel::default());
2187 let thread = cx.new(|cx| {
2188 Thread::new(
2189 project.clone(),
2190 cx.new(|_cx| ProjectContext::default()),
2191 context_server_registry,
2192 Templates::new(),
2193 Some(model.clone()),
2194 cx,
2195 )
2196 });
2197 let languages = project.read_with(cx, |project, _| project.languages().clone());
2198 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2199
2200 let read_tool = Arc::new(crate::ReadFileTool::new(
2201 thread.downgrade(),
2202 project.clone(),
2203 action_log,
2204 ));
2205 let edit_tool = Arc::new(EditFileTool::new(
2206 project.clone(),
2207 thread.downgrade(),
2208 languages,
2209 Templates::new(),
2210 ));
2211
2212 // Read the file first
2213 cx.update(|cx| {
2214 read_tool.clone().run(
2215 crate::ReadFileToolInput {
2216 path: "root/test.txt".to_string(),
2217 start_line: None,
2218 end_line: None,
2219 },
2220 ToolCallEventStream::test().0,
2221 cx,
2222 )
2223 })
2224 .await
2225 .unwrap();
2226
2227 // Open the buffer and make it dirty by editing without saving
2228 let project_path = project
2229 .read_with(cx, |project, cx| {
2230 project.find_project_path("root/test.txt", cx)
2231 })
2232 .expect("Should find project path");
2233 let buffer = project
2234 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2235 .await
2236 .unwrap();
2237
2238 // Make an in-memory edit to the buffer (making it dirty)
2239 buffer.update(cx, |buffer, cx| {
2240 let end_point = buffer.max_point();
2241 buffer.edit([(end_point..end_point, " added text")], None, cx);
2242 });
2243
2244 // Verify buffer is dirty
2245 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2246 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2247
2248 // Try to edit - should fail because buffer has unsaved changes
2249 let result = cx
2250 .update(|cx| {
2251 edit_tool.clone().run(
2252 EditFileToolInput {
2253 display_description: "Edit with dirty buffer".into(),
2254 path: "root/test.txt".into(),
2255 mode: EditFileMode::Edit,
2256 },
2257 ToolCallEventStream::test().0,
2258 cx,
2259 )
2260 })
2261 .await;
2262
2263 assert!(result.is_err(), "Edit should fail when buffer is dirty");
2264 let error_msg = result.unwrap_err().to_string();
2265 assert!(
2266 error_msg.contains("This file has unsaved changes."),
2267 "Error should mention unsaved changes, got: {}",
2268 error_msg
2269 );
2270 assert!(
2271 error_msg.contains("keep or discard"),
2272 "Error should ask whether to keep or discard changes, got: {}",
2273 error_msg
2274 );
2275 // Since save_file and restore_file_from_disk tools aren't added to the thread,
2276 // the error message should ask the user to manually save or revert
2277 assert!(
2278 error_msg.contains("save or revert the file manually"),
2279 "Error should ask user to manually save or revert when tools aren't available, got: {}",
2280 error_msg
2281 );
2282 }
2283}