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