1use crate::{AgentTool, Thread, ToolCallEventStream};
2use acp_thread::Diff;
3use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
4use anyhow::{Context as _, Result, anyhow};
5use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat};
6use cloud_llm_client::CompletionIntent;
7use collections::HashSet;
8use gpui::{App, AppContext, AsyncApp, Entity, Task};
9use indoc::formatdoc;
10use language::ToPoint;
11use language::language_settings::{self, FormatOnSave};
12use language_model::LanguageModelToolResultContent;
13use paths;
14use project::lsp_store::{FormatTrigger, LspFormatTarget};
15use project::{Project, ProjectPath};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use settings::Settings;
19use smol::stream::StreamExt as _;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use ui::SharedString;
23use util::ResultExt;
24
25const DEFAULT_UI_TEXT: &str = "Editing file";
26
27/// 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.
28///
29/// Before using this tool:
30///
31/// 1. Use the `read_file` tool to understand the file's contents and context
32///
33/// 2. Verify the directory path is correct (only applicable when creating new files):
34/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
36pub struct EditFileToolInput {
37 /// A one-line, user-friendly markdown description of the edit. This will be
38 /// shown in the UI and also passed to another model to perform the edit.
39 ///
40 /// Be terse, but also descriptive in what you want to achieve with this
41 /// edit. Avoid generic instructions.
42 ///
43 /// NEVER mention the file path in this description.
44 ///
45 /// <example>Fix API endpoint URLs</example>
46 /// <example>Update copyright year in `page_footer`</example>
47 ///
48 /// Make sure to include this field before all the others in the input object
49 /// so that we can display it immediately.
50 pub display_description: String,
51
52 /// The full path of the file to create or modify in the project.
53 ///
54 /// WARNING: When specifying which file path need changing, you MUST
55 /// start each path with one of the project's root directories.
56 ///
57 /// The following examples assume we have two root directories in the project:
58 /// - /a/b/backend
59 /// - /c/d/frontend
60 ///
61 /// <example>
62 /// `backend/src/main.rs`
63 ///
64 /// Notice how the file path starts with `backend`. Without that, the path
65 /// would be ambiguous and the call would fail!
66 /// </example>
67 ///
68 /// <example>
69 /// `frontend/db.js`
70 /// </example>
71 pub path: PathBuf,
72
73 /// The mode of operation on the file. Possible values:
74 /// - 'edit': Make granular edits to an existing file.
75 /// - 'create': Create a new file if it doesn't exist.
76 /// - 'overwrite': Replace the entire contents of an existing file.
77 ///
78 /// When a file already exists or you just created it, prefer editing
79 /// it as opposed to recreating it from scratch.
80 pub mode: EditFileMode,
81}
82
83#[derive(Debug, Serialize, Deserialize, JsonSchema)]
84struct EditFileToolPartialInput {
85 #[serde(default)]
86 path: String,
87 #[serde(default)]
88 display_description: String,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "lowercase")]
93pub enum EditFileMode {
94 Edit,
95 Create,
96 Overwrite,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct EditFileToolOutput {
101 input_path: PathBuf,
102 project_path: PathBuf,
103 new_text: String,
104 old_text: Arc<String>,
105 diff: String,
106 edit_agent_output: EditAgentOutput,
107}
108
109impl From<EditFileToolOutput> for LanguageModelToolResultContent {
110 fn from(output: EditFileToolOutput) -> Self {
111 if output.diff.is_empty() {
112 "No edits were made.".into()
113 } else {
114 format!(
115 "Edited {}:\n\n```diff\n{}\n```",
116 output.input_path.display(),
117 output.diff
118 )
119 .into()
120 }
121 }
122}
123
124pub struct EditFileTool {
125 thread: Entity<Thread>,
126}
127
128impl EditFileTool {
129 pub fn new(thread: Entity<Thread>) -> Self {
130 Self { thread }
131 }
132
133 fn authorize(
134 &self,
135 input: &EditFileToolInput,
136 event_stream: &ToolCallEventStream,
137 cx: &mut App,
138 ) -> Task<Result<()>> {
139 if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
140 return Task::ready(Ok(()));
141 }
142
143 // If any path component matches the local settings folder, then this could affect
144 // the editor in ways beyond the project source, so prompt.
145 let local_settings_folder = paths::local_settings_folder_relative_path();
146 let path = Path::new(&input.path);
147 if path
148 .components()
149 .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
150 {
151 return event_stream.authorize(
152 format!("{} (local settings)", input.display_description),
153 cx,
154 );
155 }
156
157 // It's also possible that the global config dir is configured to be inside the project,
158 // so check for that edge case too.
159 if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
160 if canonical_path.starts_with(paths::config_dir()) {
161 return event_stream.authorize(
162 format!("{} (global settings)", input.display_description),
163 cx,
164 );
165 }
166 }
167
168 // Check if path is inside the global config directory
169 // First check if it's already inside project - if not, try to canonicalize
170 let thread = self.thread.read(cx);
171 let project_path = thread.project().read(cx).find_project_path(&input.path, cx);
172
173 // If the path is inside the project, and it's not one of the above edge cases,
174 // then no confirmation is necessary. Otherwise, confirmation is necessary.
175 if project_path.is_some() {
176 Task::ready(Ok(()))
177 } else {
178 event_stream.authorize(&input.display_description, cx)
179 }
180 }
181}
182
183impl AgentTool for EditFileTool {
184 type Input = EditFileToolInput;
185 type Output = EditFileToolOutput;
186
187 fn name(&self) -> SharedString {
188 "edit_file".into()
189 }
190
191 fn kind(&self) -> acp::ToolKind {
192 acp::ToolKind::Edit
193 }
194
195 fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
196 match input {
197 Ok(input) => input.display_description.into(),
198 Err(raw_input) => {
199 if let Some(input) =
200 serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
201 {
202 let description = input.display_description.trim();
203 if !description.is_empty() {
204 return description.to_string().into();
205 }
206
207 let path = input.path.trim().to_string();
208 if !path.is_empty() {
209 return path.into();
210 }
211 }
212
213 DEFAULT_UI_TEXT.into()
214 }
215 }
216 }
217
218 fn run(
219 self: Arc<Self>,
220 input: Self::Input,
221 event_stream: ToolCallEventStream,
222 cx: &mut App,
223 ) -> Task<Result<Self::Output>> {
224 let project = self.thread.read(cx).project().clone();
225 let project_path = match resolve_path(&input, project.clone(), cx) {
226 Ok(path) => path,
227 Err(err) => return Task::ready(Err(anyhow!(err))),
228 };
229 let abs_path = project.read(cx).absolute_path(&project_path, cx);
230 if let Some(abs_path) = abs_path.clone() {
231 event_stream.update_fields(ToolCallUpdateFields {
232 locations: Some(vec![acp::ToolCallLocation {
233 path: abs_path,
234 line: None,
235 }]),
236 ..Default::default()
237 });
238 }
239
240 let request = self.thread.update(cx, |thread, cx| {
241 thread.build_completion_request(CompletionIntent::ToolResults, cx)
242 });
243 let thread = self.thread.read(cx);
244 let model = thread.model().clone();
245 let action_log = thread.action_log().clone();
246
247 let authorize = self.authorize(&input, &event_stream, cx);
248 cx.spawn(async move |cx: &mut AsyncApp| {
249 authorize.await?;
250
251 let edit_format = EditFormat::from_model(model.clone())?;
252 let edit_agent = EditAgent::new(
253 model,
254 project.clone(),
255 action_log.clone(),
256 // TODO: move edit agent to this crate so we can use our templates
257 assistant_tools::templates::Templates::new(),
258 edit_format,
259 );
260
261 let buffer = project
262 .update(cx, |project, cx| {
263 project.open_buffer(project_path.clone(), cx)
264 })?
265 .await?;
266
267 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
268 event_stream.update_diff(diff.clone());
269
270 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
271 let old_text = cx
272 .background_spawn({
273 let old_snapshot = old_snapshot.clone();
274 async move { Arc::new(old_snapshot.text()) }
275 })
276 .await;
277
278
279 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
280 edit_agent.edit(
281 buffer.clone(),
282 input.display_description.clone(),
283 &request,
284 cx,
285 )
286 } else {
287 edit_agent.overwrite(
288 buffer.clone(),
289 input.display_description.clone(),
290 &request,
291 cx,
292 )
293 };
294
295 let mut hallucinated_old_text = false;
296 let mut ambiguous_ranges = Vec::new();
297 let mut emitted_location = false;
298 while let Some(event) = events.next().await {
299 match event {
300 EditAgentOutputEvent::Edited(range) => {
301 if !emitted_location {
302 let line = buffer.update(cx, |buffer, _cx| {
303 range.start.to_point(&buffer.snapshot()).row
304 }).ok();
305 if let Some(abs_path) = abs_path.clone() {
306 event_stream.update_fields(ToolCallUpdateFields {
307 locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
308 ..Default::default()
309 });
310 }
311 emitted_location = true;
312 }
313 },
314 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
315 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
316 EditAgentOutputEvent::ResolvingEditRange(range) => {
317 diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx))?;
318 // if !emitted_location {
319 // let line = buffer.update(cx, |buffer, _cx| {
320 // range.start.to_point(&buffer.snapshot()).row
321 // }).ok();
322 // if let Some(abs_path) = abs_path.clone() {
323 // event_stream.update_fields(ToolCallUpdateFields {
324 // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
325 // ..Default::default()
326 // });
327 // }
328 // }
329 }
330 }
331 }
332
333 // If format_on_save is enabled, format the buffer
334 let format_on_save_enabled = buffer
335 .read_with(cx, |buffer, cx| {
336 let settings = language_settings::language_settings(
337 buffer.language().map(|l| l.name()),
338 buffer.file(),
339 cx,
340 );
341 settings.format_on_save != FormatOnSave::Off
342 })
343 .unwrap_or(false);
344
345 let edit_agent_output = output.await?;
346
347 if format_on_save_enabled {
348 action_log.update(cx, |log, cx| {
349 log.buffer_edited(buffer.clone(), cx);
350 })?;
351
352 let format_task = project.update(cx, |project, cx| {
353 project.format(
354 HashSet::from_iter([buffer.clone()]),
355 LspFormatTarget::Buffers,
356 false, // Don't push to history since the tool did it.
357 FormatTrigger::Save,
358 cx,
359 )
360 })?;
361 format_task.await.log_err();
362 }
363
364 project
365 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
366 .await?;
367
368 action_log.update(cx, |log, cx| {
369 log.buffer_edited(buffer.clone(), cx);
370 })?;
371
372 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
373 let (new_text, unified_diff) = cx
374 .background_spawn({
375 let new_snapshot = new_snapshot.clone();
376 let old_text = old_text.clone();
377 async move {
378 let new_text = new_snapshot.text();
379 let diff = language::unified_diff(&old_text, &new_text);
380 (new_text, diff)
381 }
382 })
383 .await;
384
385 diff.update(cx, |diff, cx| diff.finalize(cx)).ok();
386
387 let input_path = input.path.display();
388 if unified_diff.is_empty() {
389 anyhow::ensure!(
390 !hallucinated_old_text,
391 formatdoc! {"
392 Some edits were produced but none of them could be applied.
393 Read the relevant sections of {input_path} again so that
394 I can perform the requested edits.
395 "}
396 );
397 anyhow::ensure!(
398 ambiguous_ranges.is_empty(),
399 {
400 let line_numbers = ambiguous_ranges
401 .iter()
402 .map(|range| range.start.to_string())
403 .collect::<Vec<_>>()
404 .join(", ");
405 formatdoc! {"
406 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
407 relevant sections of {input_path} again and extend <old_text> so
408 that I can perform the requested edits.
409 "}
410 }
411 );
412 }
413
414 Ok(EditFileToolOutput {
415 input_path: input.path,
416 project_path: project_path.path.to_path_buf(),
417 new_text: new_text.clone(),
418 old_text,
419 diff: unified_diff,
420 edit_agent_output,
421 })
422 })
423 }
424}
425
426/// Validate that the file path is valid, meaning:
427///
428/// - For `edit` and `overwrite`, the path must point to an existing file.
429/// - For `create`, the file must not already exist, but it's parent dir must exist.
430fn resolve_path(
431 input: &EditFileToolInput,
432 project: Entity<Project>,
433 cx: &mut App,
434) -> Result<ProjectPath> {
435 let project = project.read(cx);
436
437 match input.mode {
438 EditFileMode::Edit | EditFileMode::Overwrite => {
439 let path = project
440 .find_project_path(&input.path, cx)
441 .context("Can't edit file: path not found")?;
442
443 let entry = project
444 .entry_for_path(&path, cx)
445 .context("Can't edit file: path not found")?;
446
447 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
448 Ok(path)
449 }
450
451 EditFileMode::Create => {
452 if let Some(path) = project.find_project_path(&input.path, cx) {
453 anyhow::ensure!(
454 project.entry_for_path(&path, cx).is_none(),
455 "Can't create file: file already exists"
456 );
457 }
458
459 let parent_path = input
460 .path
461 .parent()
462 .context("Can't create file: incorrect path")?;
463
464 let parent_project_path = project.find_project_path(&parent_path, cx);
465
466 let parent_entry = parent_project_path
467 .as_ref()
468 .and_then(|path| project.entry_for_path(&path, cx))
469 .context("Can't create file: parent directory doesn't exist")?;
470
471 anyhow::ensure!(
472 parent_entry.is_dir(),
473 "Can't create file: parent is not a directory"
474 );
475
476 let file_name = input
477 .path
478 .file_name()
479 .context("Can't create file: invalid filename")?;
480
481 let new_file_path = parent_project_path.map(|parent| ProjectPath {
482 path: Arc::from(parent.path.join(file_name)),
483 ..parent
484 });
485
486 new_file_path.context("Can't create file")
487 }
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::{ContextServerRegistry, Templates};
495 use action_log::ActionLog;
496 use client::TelemetrySettings;
497 use fs::Fs;
498 use gpui::{TestAppContext, UpdateGlobal};
499 use language_model::fake_provider::FakeLanguageModel;
500 use serde_json::json;
501 use settings::SettingsStore;
502 use std::rc::Rc;
503 use util::path;
504
505 #[gpui::test]
506 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
507 init_test(cx);
508
509 let fs = project::FakeFs::new(cx.executor());
510 fs.insert_tree("/root", json!({})).await;
511 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
512 let action_log = cx.new(|_| ActionLog::new(project.clone()));
513 let context_server_registry =
514 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
515 let model = Arc::new(FakeLanguageModel::default());
516 let thread = cx.new(|cx| {
517 Thread::new(
518 project,
519 Rc::default(),
520 context_server_registry,
521 action_log,
522 Templates::new(),
523 model,
524 cx,
525 )
526 });
527 let result = cx
528 .update(|cx| {
529 let input = EditFileToolInput {
530 display_description: "Some edit".into(),
531 path: "root/nonexistent_file.txt".into(),
532 mode: EditFileMode::Edit,
533 };
534 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
535 })
536 .await;
537 assert_eq!(
538 result.unwrap_err().to_string(),
539 "Can't edit file: path not found"
540 );
541 }
542
543 #[gpui::test]
544 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
545 let mode = &EditFileMode::Create;
546
547 let result = test_resolve_path(mode, "root/new.txt", cx);
548 assert_resolved_path_eq(result.await, "new.txt");
549
550 let result = test_resolve_path(mode, "new.txt", cx);
551 assert_resolved_path_eq(result.await, "new.txt");
552
553 let result = test_resolve_path(mode, "dir/new.txt", cx);
554 assert_resolved_path_eq(result.await, "dir/new.txt");
555
556 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
557 assert_eq!(
558 result.await.unwrap_err().to_string(),
559 "Can't create file: file already exists"
560 );
561
562 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
563 assert_eq!(
564 result.await.unwrap_err().to_string(),
565 "Can't create file: parent directory doesn't exist"
566 );
567 }
568
569 #[gpui::test]
570 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
571 let mode = &EditFileMode::Edit;
572
573 let path_with_root = "root/dir/subdir/existing.txt";
574 let path_without_root = "dir/subdir/existing.txt";
575 let result = test_resolve_path(mode, path_with_root, cx);
576 assert_resolved_path_eq(result.await, path_without_root);
577
578 let result = test_resolve_path(mode, path_without_root, cx);
579 assert_resolved_path_eq(result.await, path_without_root);
580
581 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
582 assert_eq!(
583 result.await.unwrap_err().to_string(),
584 "Can't edit file: path not found"
585 );
586
587 let result = test_resolve_path(mode, "root/dir", cx);
588 assert_eq!(
589 result.await.unwrap_err().to_string(),
590 "Can't edit file: path is a directory"
591 );
592 }
593
594 async fn test_resolve_path(
595 mode: &EditFileMode,
596 path: &str,
597 cx: &mut TestAppContext,
598 ) -> anyhow::Result<ProjectPath> {
599 init_test(cx);
600
601 let fs = project::FakeFs::new(cx.executor());
602 fs.insert_tree(
603 "/root",
604 json!({
605 "dir": {
606 "subdir": {
607 "existing.txt": "hello"
608 }
609 }
610 }),
611 )
612 .await;
613 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
614
615 let input = EditFileToolInput {
616 display_description: "Some edit".into(),
617 path: path.into(),
618 mode: mode.clone(),
619 };
620
621 let result = cx.update(|cx| resolve_path(&input, project, cx));
622 result
623 }
624
625 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
626 let actual = path
627 .expect("Should return valid path")
628 .path
629 .to_str()
630 .unwrap()
631 .replace("\\", "/"); // Naive Windows paths normalization
632 assert_eq!(actual, expected);
633 }
634
635 #[gpui::test]
636 async fn test_format_on_save(cx: &mut TestAppContext) {
637 init_test(cx);
638
639 let fs = project::FakeFs::new(cx.executor());
640 fs.insert_tree("/root", json!({"src": {}})).await;
641
642 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
643
644 // Set up a Rust language with LSP formatting support
645 let rust_language = Arc::new(language::Language::new(
646 language::LanguageConfig {
647 name: "Rust".into(),
648 matcher: language::LanguageMatcher {
649 path_suffixes: vec!["rs".to_string()],
650 ..Default::default()
651 },
652 ..Default::default()
653 },
654 None,
655 ));
656
657 // Register the language and fake LSP
658 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
659 language_registry.add(rust_language);
660
661 let mut fake_language_servers = language_registry.register_fake_lsp(
662 "Rust",
663 language::FakeLspAdapter {
664 capabilities: lsp::ServerCapabilities {
665 document_formatting_provider: Some(lsp::OneOf::Left(true)),
666 ..Default::default()
667 },
668 ..Default::default()
669 },
670 );
671
672 // Create the file
673 fs.save(
674 path!("/root/src/main.rs").as_ref(),
675 &"initial content".into(),
676 language::LineEnding::Unix,
677 )
678 .await
679 .unwrap();
680
681 // Open the buffer to trigger LSP initialization
682 let buffer = project
683 .update(cx, |project, cx| {
684 project.open_local_buffer(path!("/root/src/main.rs"), cx)
685 })
686 .await
687 .unwrap();
688
689 // Register the buffer with language servers
690 let _handle = project.update(cx, |project, cx| {
691 project.register_buffer_with_language_servers(&buffer, cx)
692 });
693
694 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
695 const FORMATTED_CONTENT: &str =
696 "This file was formatted by the fake formatter in the test.\n";
697
698 // Get the fake language server and set up formatting handler
699 let fake_language_server = fake_language_servers.next().await.unwrap();
700 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
701 |_, _| async move {
702 Ok(Some(vec![lsp::TextEdit {
703 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
704 new_text: FORMATTED_CONTENT.to_string(),
705 }]))
706 }
707 });
708
709 let action_log = cx.new(|_| ActionLog::new(project.clone()));
710 let context_server_registry =
711 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
712 let model = Arc::new(FakeLanguageModel::default());
713 let thread = cx.new(|cx| {
714 Thread::new(
715 project,
716 Rc::default(),
717 context_server_registry,
718 action_log.clone(),
719 Templates::new(),
720 model.clone(),
721 cx,
722 )
723 });
724
725 // First, test with format_on_save enabled
726 cx.update(|cx| {
727 SettingsStore::update_global(cx, |store, cx| {
728 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
729 cx,
730 |settings| {
731 settings.defaults.format_on_save = Some(FormatOnSave::On);
732 settings.defaults.formatter =
733 Some(language::language_settings::SelectedFormatter::Auto);
734 },
735 );
736 });
737 });
738
739 // Have the model stream unformatted content
740 let edit_result = {
741 let edit_task = cx.update(|cx| {
742 let input = EditFileToolInput {
743 display_description: "Create main function".into(),
744 path: "root/src/main.rs".into(),
745 mode: EditFileMode::Overwrite,
746 };
747 Arc::new(EditFileTool {
748 thread: thread.clone(),
749 })
750 .run(input, ToolCallEventStream::test().0, cx)
751 });
752
753 // Stream the unformatted content
754 cx.executor().run_until_parked();
755 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
756 model.end_last_completion_stream();
757
758 edit_task.await
759 };
760 assert!(edit_result.is_ok());
761
762 // Wait for any async operations (e.g. formatting) to complete
763 cx.executor().run_until_parked();
764
765 // Read the file to verify it was formatted automatically
766 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
767 assert_eq!(
768 // Ignore carriage returns on Windows
769 new_content.replace("\r\n", "\n"),
770 FORMATTED_CONTENT,
771 "Code should be formatted when format_on_save is enabled"
772 );
773
774 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
775
776 assert_eq!(
777 stale_buffer_count, 0,
778 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
779 This causes the agent to think the file was modified externally when it was just formatted.",
780 stale_buffer_count
781 );
782
783 // Next, test with format_on_save disabled
784 cx.update(|cx| {
785 SettingsStore::update_global(cx, |store, cx| {
786 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
787 cx,
788 |settings| {
789 settings.defaults.format_on_save = Some(FormatOnSave::Off);
790 },
791 );
792 });
793 });
794
795 // Stream unformatted edits again
796 let edit_result = {
797 let edit_task = cx.update(|cx| {
798 let input = EditFileToolInput {
799 display_description: "Update main function".into(),
800 path: "root/src/main.rs".into(),
801 mode: EditFileMode::Overwrite,
802 };
803 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
804 });
805
806 // Stream the unformatted content
807 cx.executor().run_until_parked();
808 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
809 model.end_last_completion_stream();
810
811 edit_task.await
812 };
813 assert!(edit_result.is_ok());
814
815 // Wait for any async operations (e.g. formatting) to complete
816 cx.executor().run_until_parked();
817
818 // Verify the file was not formatted
819 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
820 assert_eq!(
821 // Ignore carriage returns on Windows
822 new_content.replace("\r\n", "\n"),
823 UNFORMATTED_CONTENT,
824 "Code should not be formatted when format_on_save is disabled"
825 );
826 }
827
828 #[gpui::test]
829 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
830 init_test(cx);
831
832 let fs = project::FakeFs::new(cx.executor());
833 fs.insert_tree("/root", json!({"src": {}})).await;
834
835 // Create a simple file with trailing whitespace
836 fs.save(
837 path!("/root/src/main.rs").as_ref(),
838 &"initial content".into(),
839 language::LineEnding::Unix,
840 )
841 .await
842 .unwrap();
843
844 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
845 let context_server_registry =
846 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
847 let action_log = cx.new(|_| ActionLog::new(project.clone()));
848 let model = Arc::new(FakeLanguageModel::default());
849 let thread = cx.new(|cx| {
850 Thread::new(
851 project,
852 Rc::default(),
853 context_server_registry,
854 action_log.clone(),
855 Templates::new(),
856 model.clone(),
857 cx,
858 )
859 });
860
861 // First, test with remove_trailing_whitespace_on_save enabled
862 cx.update(|cx| {
863 SettingsStore::update_global(cx, |store, cx| {
864 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
865 cx,
866 |settings| {
867 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
868 },
869 );
870 });
871 });
872
873 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
874 "fn main() { \n println!(\"Hello!\"); \n}\n";
875
876 // Have the model stream content that contains trailing whitespace
877 let edit_result = {
878 let edit_task = cx.update(|cx| {
879 let input = EditFileToolInput {
880 display_description: "Create main function".into(),
881 path: "root/src/main.rs".into(),
882 mode: EditFileMode::Overwrite,
883 };
884 Arc::new(EditFileTool {
885 thread: thread.clone(),
886 })
887 .run(input, ToolCallEventStream::test().0, cx)
888 });
889
890 // Stream the content with trailing whitespace
891 cx.executor().run_until_parked();
892 model.send_last_completion_stream_text_chunk(
893 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
894 );
895 model.end_last_completion_stream();
896
897 edit_task.await
898 };
899 assert!(edit_result.is_ok());
900
901 // Wait for any async operations (e.g. formatting) to complete
902 cx.executor().run_until_parked();
903
904 // Read the file to verify trailing whitespace was removed automatically
905 assert_eq!(
906 // Ignore carriage returns on Windows
907 fs.load(path!("/root/src/main.rs").as_ref())
908 .await
909 .unwrap()
910 .replace("\r\n", "\n"),
911 "fn main() {\n println!(\"Hello!\");\n}\n",
912 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
913 );
914
915 // Next, test with remove_trailing_whitespace_on_save disabled
916 cx.update(|cx| {
917 SettingsStore::update_global(cx, |store, cx| {
918 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
919 cx,
920 |settings| {
921 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
922 },
923 );
924 });
925 });
926
927 // Stream edits again with trailing whitespace
928 let edit_result = {
929 let edit_task = cx.update(|cx| {
930 let input = EditFileToolInput {
931 display_description: "Update main function".into(),
932 path: "root/src/main.rs".into(),
933 mode: EditFileMode::Overwrite,
934 };
935 Arc::new(EditFileTool {
936 thread: thread.clone(),
937 })
938 .run(input, ToolCallEventStream::test().0, cx)
939 });
940
941 // Stream the content with trailing whitespace
942 cx.executor().run_until_parked();
943 model.send_last_completion_stream_text_chunk(
944 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
945 );
946 model.end_last_completion_stream();
947
948 edit_task.await
949 };
950 assert!(edit_result.is_ok());
951
952 // Wait for any async operations (e.g. formatting) to complete
953 cx.executor().run_until_parked();
954
955 // Verify the file still has trailing whitespace
956 // Read the file again - it should still have trailing whitespace
957 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
958 assert_eq!(
959 // Ignore carriage returns on Windows
960 final_content.replace("\r\n", "\n"),
961 CONTENT_WITH_TRAILING_WHITESPACE,
962 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
963 );
964 }
965
966 #[gpui::test]
967 async fn test_authorize(cx: &mut TestAppContext) {
968 init_test(cx);
969 let fs = project::FakeFs::new(cx.executor());
970 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
971 let context_server_registry =
972 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
973 let action_log = cx.new(|_| ActionLog::new(project.clone()));
974 let model = Arc::new(FakeLanguageModel::default());
975 let thread = cx.new(|cx| {
976 Thread::new(
977 project,
978 Rc::default(),
979 context_server_registry,
980 action_log.clone(),
981 Templates::new(),
982 model.clone(),
983 cx,
984 )
985 });
986 let tool = Arc::new(EditFileTool { thread });
987 fs.insert_tree("/root", json!({})).await;
988
989 // Test 1: Path with .zed component should require confirmation
990 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
991 let _auth = cx.update(|cx| {
992 tool.authorize(
993 &EditFileToolInput {
994 display_description: "test 1".into(),
995 path: ".zed/settings.json".into(),
996 mode: EditFileMode::Edit,
997 },
998 &stream_tx,
999 cx,
1000 )
1001 });
1002
1003 let event = stream_rx.expect_authorization().await;
1004 assert_eq!(
1005 event.tool_call.fields.title,
1006 Some("test 1 (local settings)".into())
1007 );
1008
1009 // Test 2: Path outside project should require confirmation
1010 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1011 let _auth = cx.update(|cx| {
1012 tool.authorize(
1013 &EditFileToolInput {
1014 display_description: "test 2".into(),
1015 path: "/etc/hosts".into(),
1016 mode: EditFileMode::Edit,
1017 },
1018 &stream_tx,
1019 cx,
1020 )
1021 });
1022
1023 let event = stream_rx.expect_authorization().await;
1024 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1025
1026 // Test 3: Relative path without .zed should not require confirmation
1027 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1028 cx.update(|cx| {
1029 tool.authorize(
1030 &EditFileToolInput {
1031 display_description: "test 3".into(),
1032 path: "root/src/main.rs".into(),
1033 mode: EditFileMode::Edit,
1034 },
1035 &stream_tx,
1036 cx,
1037 )
1038 })
1039 .await
1040 .unwrap();
1041 assert!(stream_rx.try_next().is_err());
1042
1043 // Test 4: Path with .zed in the middle should require confirmation
1044 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1045 let _auth = cx.update(|cx| {
1046 tool.authorize(
1047 &EditFileToolInput {
1048 display_description: "test 4".into(),
1049 path: "root/.zed/tasks.json".into(),
1050 mode: EditFileMode::Edit,
1051 },
1052 &stream_tx,
1053 cx,
1054 )
1055 });
1056 let event = stream_rx.expect_authorization().await;
1057 assert_eq!(
1058 event.tool_call.fields.title,
1059 Some("test 4 (local settings)".into())
1060 );
1061
1062 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1063 cx.update(|cx| {
1064 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1065 settings.always_allow_tool_actions = true;
1066 agent_settings::AgentSettings::override_global(settings, cx);
1067 });
1068
1069 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1070 cx.update(|cx| {
1071 tool.authorize(
1072 &EditFileToolInput {
1073 display_description: "test 5.1".into(),
1074 path: ".zed/settings.json".into(),
1075 mode: EditFileMode::Edit,
1076 },
1077 &stream_tx,
1078 cx,
1079 )
1080 })
1081 .await
1082 .unwrap();
1083 assert!(stream_rx.try_next().is_err());
1084
1085 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1086 cx.update(|cx| {
1087 tool.authorize(
1088 &EditFileToolInput {
1089 display_description: "test 5.2".into(),
1090 path: "/etc/hosts".into(),
1091 mode: EditFileMode::Edit,
1092 },
1093 &stream_tx,
1094 cx,
1095 )
1096 })
1097 .await
1098 .unwrap();
1099 assert!(stream_rx.try_next().is_err());
1100 }
1101
1102 #[gpui::test]
1103 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1104 init_test(cx);
1105 let fs = project::FakeFs::new(cx.executor());
1106 fs.insert_tree("/project", json!({})).await;
1107 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1108 let context_server_registry =
1109 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1110 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1111 let model = Arc::new(FakeLanguageModel::default());
1112 let thread = cx.new(|cx| {
1113 Thread::new(
1114 project,
1115 Rc::default(),
1116 context_server_registry,
1117 action_log.clone(),
1118 Templates::new(),
1119 model.clone(),
1120 cx,
1121 )
1122 });
1123 let tool = Arc::new(EditFileTool { thread });
1124
1125 // Test global config paths - these should require confirmation if they exist and are outside the project
1126 let test_cases = vec![
1127 (
1128 "/etc/hosts",
1129 true,
1130 "System file should require confirmation",
1131 ),
1132 (
1133 "/usr/local/bin/script",
1134 true,
1135 "System bin file should require confirmation",
1136 ),
1137 (
1138 "project/normal_file.rs",
1139 false,
1140 "Normal project file should not require confirmation",
1141 ),
1142 ];
1143
1144 for (path, should_confirm, description) in test_cases {
1145 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1146 let auth = cx.update(|cx| {
1147 tool.authorize(
1148 &EditFileToolInput {
1149 display_description: "Edit file".into(),
1150 path: path.into(),
1151 mode: EditFileMode::Edit,
1152 },
1153 &stream_tx,
1154 cx,
1155 )
1156 });
1157
1158 if should_confirm {
1159 stream_rx.expect_authorization().await;
1160 } else {
1161 auth.await.unwrap();
1162 assert!(
1163 stream_rx.try_next().is_err(),
1164 "Failed for case: {} - path: {} - expected no confirmation but got one",
1165 description,
1166 path
1167 );
1168 }
1169 }
1170 }
1171
1172 #[gpui::test]
1173 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1174 init_test(cx);
1175 let fs = project::FakeFs::new(cx.executor());
1176
1177 // Create multiple worktree directories
1178 fs.insert_tree(
1179 "/workspace/frontend",
1180 json!({
1181 "src": {
1182 "main.js": "console.log('frontend');"
1183 }
1184 }),
1185 )
1186 .await;
1187 fs.insert_tree(
1188 "/workspace/backend",
1189 json!({
1190 "src": {
1191 "main.rs": "fn main() {}"
1192 }
1193 }),
1194 )
1195 .await;
1196 fs.insert_tree(
1197 "/workspace/shared",
1198 json!({
1199 ".zed": {
1200 "settings.json": "{}"
1201 }
1202 }),
1203 )
1204 .await;
1205
1206 // Create project with multiple worktrees
1207 let project = Project::test(
1208 fs.clone(),
1209 [
1210 path!("/workspace/frontend").as_ref(),
1211 path!("/workspace/backend").as_ref(),
1212 path!("/workspace/shared").as_ref(),
1213 ],
1214 cx,
1215 )
1216 .await;
1217
1218 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1219 let context_server_registry =
1220 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1221 let model = Arc::new(FakeLanguageModel::default());
1222 let thread = cx.new(|cx| {
1223 Thread::new(
1224 project.clone(),
1225 Rc::default(),
1226 context_server_registry.clone(),
1227 action_log.clone(),
1228 Templates::new(),
1229 model.clone(),
1230 cx,
1231 )
1232 });
1233 let tool = Arc::new(EditFileTool { thread });
1234
1235 // Test files in different worktrees
1236 let test_cases = vec![
1237 ("frontend/src/main.js", false, "File in first worktree"),
1238 ("backend/src/main.rs", false, "File in second worktree"),
1239 (
1240 "shared/.zed/settings.json",
1241 true,
1242 ".zed file in third worktree",
1243 ),
1244 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1245 (
1246 "../outside/file.txt",
1247 true,
1248 "Relative path outside worktrees",
1249 ),
1250 ];
1251
1252 for (path, should_confirm, description) in test_cases {
1253 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1254 let auth = cx.update(|cx| {
1255 tool.authorize(
1256 &EditFileToolInput {
1257 display_description: "Edit file".into(),
1258 path: path.into(),
1259 mode: EditFileMode::Edit,
1260 },
1261 &stream_tx,
1262 cx,
1263 )
1264 });
1265
1266 if should_confirm {
1267 stream_rx.expect_authorization().await;
1268 } else {
1269 auth.await.unwrap();
1270 assert!(
1271 stream_rx.try_next().is_err(),
1272 "Failed for case: {} - path: {} - expected no confirmation but got one",
1273 description,
1274 path
1275 );
1276 }
1277 }
1278 }
1279
1280 #[gpui::test]
1281 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1282 init_test(cx);
1283 let fs = project::FakeFs::new(cx.executor());
1284 fs.insert_tree(
1285 "/project",
1286 json!({
1287 ".zed": {
1288 "settings.json": "{}"
1289 },
1290 "src": {
1291 ".zed": {
1292 "local.json": "{}"
1293 }
1294 }
1295 }),
1296 )
1297 .await;
1298 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1299 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1300 let context_server_registry =
1301 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1302 let model = Arc::new(FakeLanguageModel::default());
1303 let thread = cx.new(|cx| {
1304 Thread::new(
1305 project.clone(),
1306 Rc::default(),
1307 context_server_registry.clone(),
1308 action_log.clone(),
1309 Templates::new(),
1310 model.clone(),
1311 cx,
1312 )
1313 });
1314 let tool = Arc::new(EditFileTool { thread });
1315
1316 // Test edge cases
1317 let test_cases = vec![
1318 // Empty path - find_project_path returns Some for empty paths
1319 ("", false, "Empty path is treated as project root"),
1320 // Root directory
1321 ("/", true, "Root directory should be outside project"),
1322 // Parent directory references - find_project_path resolves these
1323 (
1324 "project/../other",
1325 false,
1326 "Path with .. is resolved by find_project_path",
1327 ),
1328 (
1329 "project/./src/file.rs",
1330 false,
1331 "Path with . should work normally",
1332 ),
1333 // Windows-style paths (if on Windows)
1334 #[cfg(target_os = "windows")]
1335 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1336 #[cfg(target_os = "windows")]
1337 ("project\\src\\main.rs", false, "Windows-style project path"),
1338 ];
1339
1340 for (path, should_confirm, description) in test_cases {
1341 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1342 let auth = cx.update(|cx| {
1343 tool.authorize(
1344 &EditFileToolInput {
1345 display_description: "Edit file".into(),
1346 path: path.into(),
1347 mode: EditFileMode::Edit,
1348 },
1349 &stream_tx,
1350 cx,
1351 )
1352 });
1353
1354 if should_confirm {
1355 stream_rx.expect_authorization().await;
1356 } else {
1357 auth.await.unwrap();
1358 assert!(
1359 stream_rx.try_next().is_err(),
1360 "Failed for case: {} - path: {} - expected no confirmation but got one",
1361 description,
1362 path
1363 );
1364 }
1365 }
1366 }
1367
1368 #[gpui::test]
1369 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1370 init_test(cx);
1371 let fs = project::FakeFs::new(cx.executor());
1372 fs.insert_tree(
1373 "/project",
1374 json!({
1375 "existing.txt": "content",
1376 ".zed": {
1377 "settings.json": "{}"
1378 }
1379 }),
1380 )
1381 .await;
1382 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1383 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1384 let context_server_registry =
1385 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1386 let model = Arc::new(FakeLanguageModel::default());
1387 let thread = cx.new(|cx| {
1388 Thread::new(
1389 project.clone(),
1390 Rc::default(),
1391 context_server_registry.clone(),
1392 action_log.clone(),
1393 Templates::new(),
1394 model.clone(),
1395 cx,
1396 )
1397 });
1398 let tool = Arc::new(EditFileTool { thread });
1399
1400 // Test different EditFileMode values
1401 let modes = vec![
1402 EditFileMode::Edit,
1403 EditFileMode::Create,
1404 EditFileMode::Overwrite,
1405 ];
1406
1407 for mode in modes {
1408 // Test .zed path with different modes
1409 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1410 let _auth = cx.update(|cx| {
1411 tool.authorize(
1412 &EditFileToolInput {
1413 display_description: "Edit settings".into(),
1414 path: "project/.zed/settings.json".into(),
1415 mode: mode.clone(),
1416 },
1417 &stream_tx,
1418 cx,
1419 )
1420 });
1421
1422 stream_rx.expect_authorization().await;
1423
1424 // Test outside path with different modes
1425 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1426 let _auth = cx.update(|cx| {
1427 tool.authorize(
1428 &EditFileToolInput {
1429 display_description: "Edit file".into(),
1430 path: "/outside/file.txt".into(),
1431 mode: mode.clone(),
1432 },
1433 &stream_tx,
1434 cx,
1435 )
1436 });
1437
1438 stream_rx.expect_authorization().await;
1439
1440 // Test normal path with different modes
1441 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1442 cx.update(|cx| {
1443 tool.authorize(
1444 &EditFileToolInput {
1445 display_description: "Edit file".into(),
1446 path: "project/normal.txt".into(),
1447 mode: mode.clone(),
1448 },
1449 &stream_tx,
1450 cx,
1451 )
1452 })
1453 .await
1454 .unwrap();
1455 assert!(stream_rx.try_next().is_err());
1456 }
1457 }
1458
1459 #[gpui::test]
1460 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1461 init_test(cx);
1462 let fs = project::FakeFs::new(cx.executor());
1463 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1464 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1465 let context_server_registry =
1466 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1467 let model = Arc::new(FakeLanguageModel::default());
1468 let thread = cx.new(|cx| {
1469 Thread::new(
1470 project.clone(),
1471 Rc::default(),
1472 context_server_registry,
1473 action_log.clone(),
1474 Templates::new(),
1475 model.clone(),
1476 cx,
1477 )
1478 });
1479 let tool = Arc::new(EditFileTool { thread });
1480
1481 assert_eq!(
1482 tool.initial_title(Err(json!({
1483 "path": "src/main.rs",
1484 "display_description": "",
1485 "old_string": "old code",
1486 "new_string": "new code"
1487 }))),
1488 "src/main.rs"
1489 );
1490 assert_eq!(
1491 tool.initial_title(Err(json!({
1492 "path": "",
1493 "display_description": "Fix error handling",
1494 "old_string": "old code",
1495 "new_string": "new code"
1496 }))),
1497 "Fix error handling"
1498 );
1499 assert_eq!(
1500 tool.initial_title(Err(json!({
1501 "path": "src/main.rs",
1502 "display_description": "Fix error handling",
1503 "old_string": "old code",
1504 "new_string": "new code"
1505 }))),
1506 "Fix error handling"
1507 );
1508 assert_eq!(
1509 tool.initial_title(Err(json!({
1510 "path": "",
1511 "display_description": "",
1512 "old_string": "old code",
1513 "new_string": "new code"
1514 }))),
1515 DEFAULT_UI_TEXT
1516 );
1517 assert_eq!(
1518 tool.initial_title(Err(serde_json::Value::Null)),
1519 DEFAULT_UI_TEXT
1520 );
1521 }
1522
1523 fn init_test(cx: &mut TestAppContext) {
1524 cx.update(|cx| {
1525 let settings_store = SettingsStore::test(cx);
1526 cx.set_global(settings_store);
1527 language::init(cx);
1528 TelemetrySettings::register(cx);
1529 agent_settings::AgentSettings::register(cx);
1530 Project::init_settings(cx);
1531 });
1532 }
1533}