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