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: &mut 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 event_stream.authorize(
151 format!("{} (local settings)", input.display_description),
152 cx,
153 );
154 }
155
156 // It's also possible that the global config dir is configured to be inside the project,
157 // so check for that edge case too.
158 if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
159 if canonical_path.starts_with(paths::config_dir()) {
160 return event_stream.authorize(
161 format!("{} (global settings)", input.display_description),
162 cx,
163 );
164 }
165 }
166
167 // Check if path is inside the global config directory
168 // First check if it's already inside project - if not, try to canonicalize
169 let thread = self.thread.read(cx);
170 let project_path = thread.project().read(cx).find_project_path(&input.path, cx);
171
172 // If the path is inside the project, and it's not one of the above edge cases,
173 // then no confirmation is necessary. Otherwise, confirmation is necessary.
174 if project_path.is_some() {
175 Task::ready(Ok(()))
176 } else {
177 event_stream.authorize(&input.display_description, cx)
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 super::*;
458 use crate::{ContextServerRegistry, Templates};
459 use action_log::ActionLog;
460 use client::TelemetrySettings;
461 use fs::Fs;
462 use gpui::{TestAppContext, UpdateGlobal};
463 use language_model::fake_provider::FakeLanguageModel;
464 use serde_json::json;
465 use settings::SettingsStore;
466 use std::rc::Rc;
467 use util::path;
468
469 #[gpui::test]
470 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
471 init_test(cx);
472
473 let fs = project::FakeFs::new(cx.executor());
474 fs.insert_tree("/root", json!({})).await;
475 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
476 let action_log = cx.new(|_| ActionLog::new(project.clone()));
477 let context_server_registry =
478 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
479 let model = Arc::new(FakeLanguageModel::default());
480 let thread = cx.new(|cx| {
481 Thread::new(
482 project,
483 Rc::default(),
484 context_server_registry,
485 action_log,
486 Templates::new(),
487 model,
488 cx,
489 )
490 });
491 let result = cx
492 .update(|cx| {
493 let input = EditFileToolInput {
494 display_description: "Some edit".into(),
495 path: "root/nonexistent_file.txt".into(),
496 mode: EditFileMode::Edit,
497 };
498 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
499 })
500 .await;
501 assert_eq!(
502 result.unwrap_err().to_string(),
503 "Can't edit file: path not found"
504 );
505 }
506
507 #[gpui::test]
508 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
509 let mode = &EditFileMode::Create;
510
511 let result = test_resolve_path(mode, "root/new.txt", cx);
512 assert_resolved_path_eq(result.await, "new.txt");
513
514 let result = test_resolve_path(mode, "new.txt", cx);
515 assert_resolved_path_eq(result.await, "new.txt");
516
517 let result = test_resolve_path(mode, "dir/new.txt", cx);
518 assert_resolved_path_eq(result.await, "dir/new.txt");
519
520 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
521 assert_eq!(
522 result.await.unwrap_err().to_string(),
523 "Can't create file: file already exists"
524 );
525
526 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
527 assert_eq!(
528 result.await.unwrap_err().to_string(),
529 "Can't create file: parent directory doesn't exist"
530 );
531 }
532
533 #[gpui::test]
534 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
535 let mode = &EditFileMode::Edit;
536
537 let path_with_root = "root/dir/subdir/existing.txt";
538 let path_without_root = "dir/subdir/existing.txt";
539 let result = test_resolve_path(mode, path_with_root, cx);
540 assert_resolved_path_eq(result.await, path_without_root);
541
542 let result = test_resolve_path(mode, path_without_root, cx);
543 assert_resolved_path_eq(result.await, path_without_root);
544
545 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
546 assert_eq!(
547 result.await.unwrap_err().to_string(),
548 "Can't edit file: path not found"
549 );
550
551 let result = test_resolve_path(mode, "root/dir", cx);
552 assert_eq!(
553 result.await.unwrap_err().to_string(),
554 "Can't edit file: path is a directory"
555 );
556 }
557
558 async fn test_resolve_path(
559 mode: &EditFileMode,
560 path: &str,
561 cx: &mut TestAppContext,
562 ) -> anyhow::Result<ProjectPath> {
563 init_test(cx);
564
565 let fs = project::FakeFs::new(cx.executor());
566 fs.insert_tree(
567 "/root",
568 json!({
569 "dir": {
570 "subdir": {
571 "existing.txt": "hello"
572 }
573 }
574 }),
575 )
576 .await;
577 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
578
579 let input = EditFileToolInput {
580 display_description: "Some edit".into(),
581 path: path.into(),
582 mode: mode.clone(),
583 };
584
585 let result = cx.update(|cx| resolve_path(&input, project, cx));
586 result
587 }
588
589 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
590 let actual = path
591 .expect("Should return valid path")
592 .path
593 .to_str()
594 .unwrap()
595 .replace("\\", "/"); // Naive Windows paths normalization
596 assert_eq!(actual, expected);
597 }
598
599 #[gpui::test]
600 async fn test_format_on_save(cx: &mut TestAppContext) {
601 init_test(cx);
602
603 let fs = project::FakeFs::new(cx.executor());
604 fs.insert_tree("/root", json!({"src": {}})).await;
605
606 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
607
608 // Set up a Rust language with LSP formatting support
609 let rust_language = Arc::new(language::Language::new(
610 language::LanguageConfig {
611 name: "Rust".into(),
612 matcher: language::LanguageMatcher {
613 path_suffixes: vec!["rs".to_string()],
614 ..Default::default()
615 },
616 ..Default::default()
617 },
618 None,
619 ));
620
621 // Register the language and fake LSP
622 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
623 language_registry.add(rust_language);
624
625 let mut fake_language_servers = language_registry.register_fake_lsp(
626 "Rust",
627 language::FakeLspAdapter {
628 capabilities: lsp::ServerCapabilities {
629 document_formatting_provider: Some(lsp::OneOf::Left(true)),
630 ..Default::default()
631 },
632 ..Default::default()
633 },
634 );
635
636 // Create the file
637 fs.save(
638 path!("/root/src/main.rs").as_ref(),
639 &"initial content".into(),
640 language::LineEnding::Unix,
641 )
642 .await
643 .unwrap();
644
645 // Open the buffer to trigger LSP initialization
646 let buffer = project
647 .update(cx, |project, cx| {
648 project.open_local_buffer(path!("/root/src/main.rs"), cx)
649 })
650 .await
651 .unwrap();
652
653 // Register the buffer with language servers
654 let _handle = project.update(cx, |project, cx| {
655 project.register_buffer_with_language_servers(&buffer, cx)
656 });
657
658 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
659 const FORMATTED_CONTENT: &str =
660 "This file was formatted by the fake formatter in the test.\n";
661
662 // Get the fake language server and set up formatting handler
663 let fake_language_server = fake_language_servers.next().await.unwrap();
664 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
665 |_, _| async move {
666 Ok(Some(vec![lsp::TextEdit {
667 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
668 new_text: FORMATTED_CONTENT.to_string(),
669 }]))
670 }
671 });
672
673 let action_log = cx.new(|_| ActionLog::new(project.clone()));
674 let context_server_registry =
675 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
676 let model = Arc::new(FakeLanguageModel::default());
677 let thread = cx.new(|cx| {
678 Thread::new(
679 project,
680 Rc::default(),
681 context_server_registry,
682 action_log.clone(),
683 Templates::new(),
684 model.clone(),
685 cx,
686 )
687 });
688
689 // First, test with format_on_save enabled
690 cx.update(|cx| {
691 SettingsStore::update_global(cx, |store, cx| {
692 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
693 cx,
694 |settings| {
695 settings.defaults.format_on_save = Some(FormatOnSave::On);
696 settings.defaults.formatter =
697 Some(language::language_settings::SelectedFormatter::Auto);
698 },
699 );
700 });
701 });
702
703 // Have the model stream unformatted content
704 let edit_result = {
705 let edit_task = cx.update(|cx| {
706 let input = EditFileToolInput {
707 display_description: "Create main function".into(),
708 path: "root/src/main.rs".into(),
709 mode: EditFileMode::Overwrite,
710 };
711 Arc::new(EditFileTool {
712 thread: thread.clone(),
713 })
714 .run(input, ToolCallEventStream::test().0, cx)
715 });
716
717 // Stream the unformatted content
718 cx.executor().run_until_parked();
719 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
720 model.end_last_completion_stream();
721
722 edit_task.await
723 };
724 assert!(edit_result.is_ok());
725
726 // Wait for any async operations (e.g. formatting) to complete
727 cx.executor().run_until_parked();
728
729 // Read the file to verify it was formatted automatically
730 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
731 assert_eq!(
732 // Ignore carriage returns on Windows
733 new_content.replace("\r\n", "\n"),
734 FORMATTED_CONTENT,
735 "Code should be formatted when format_on_save is enabled"
736 );
737
738 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
739
740 assert_eq!(
741 stale_buffer_count, 0,
742 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
743 This causes the agent to think the file was modified externally when it was just formatted.",
744 stale_buffer_count
745 );
746
747 // Next, test with format_on_save disabled
748 cx.update(|cx| {
749 SettingsStore::update_global(cx, |store, cx| {
750 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
751 cx,
752 |settings| {
753 settings.defaults.format_on_save = Some(FormatOnSave::Off);
754 },
755 );
756 });
757 });
758
759 // Stream unformatted edits again
760 let edit_result = {
761 let edit_task = cx.update(|cx| {
762 let input = EditFileToolInput {
763 display_description: "Update main function".into(),
764 path: "root/src/main.rs".into(),
765 mode: EditFileMode::Overwrite,
766 };
767 Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx)
768 });
769
770 // Stream the unformatted content
771 cx.executor().run_until_parked();
772 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
773 model.end_last_completion_stream();
774
775 edit_task.await
776 };
777 assert!(edit_result.is_ok());
778
779 // Wait for any async operations (e.g. formatting) to complete
780 cx.executor().run_until_parked();
781
782 // Verify the file was not formatted
783 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
784 assert_eq!(
785 // Ignore carriage returns on Windows
786 new_content.replace("\r\n", "\n"),
787 UNFORMATTED_CONTENT,
788 "Code should not be formatted when format_on_save is disabled"
789 );
790 }
791
792 #[gpui::test]
793 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
794 init_test(cx);
795
796 let fs = project::FakeFs::new(cx.executor());
797 fs.insert_tree("/root", json!({"src": {}})).await;
798
799 // Create a simple file with trailing whitespace
800 fs.save(
801 path!("/root/src/main.rs").as_ref(),
802 &"initial content".into(),
803 language::LineEnding::Unix,
804 )
805 .await
806 .unwrap();
807
808 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
809 let context_server_registry =
810 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
811 let action_log = cx.new(|_| ActionLog::new(project.clone()));
812 let model = Arc::new(FakeLanguageModel::default());
813 let thread = cx.new(|cx| {
814 Thread::new(
815 project,
816 Rc::default(),
817 context_server_registry,
818 action_log.clone(),
819 Templates::new(),
820 model.clone(),
821 cx,
822 )
823 });
824
825 // First, test with remove_trailing_whitespace_on_save enabled
826 cx.update(|cx| {
827 SettingsStore::update_global(cx, |store, cx| {
828 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
829 cx,
830 |settings| {
831 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
832 },
833 );
834 });
835 });
836
837 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
838 "fn main() { \n println!(\"Hello!\"); \n}\n";
839
840 // Have the model stream content that contains trailing whitespace
841 let edit_result = {
842 let edit_task = cx.update(|cx| {
843 let input = EditFileToolInput {
844 display_description: "Create main function".into(),
845 path: "root/src/main.rs".into(),
846 mode: EditFileMode::Overwrite,
847 };
848 Arc::new(EditFileTool {
849 thread: thread.clone(),
850 })
851 .run(input, ToolCallEventStream::test().0, cx)
852 });
853
854 // Stream the content with trailing whitespace
855 cx.executor().run_until_parked();
856 model.send_last_completion_stream_text_chunk(
857 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
858 );
859 model.end_last_completion_stream();
860
861 edit_task.await
862 };
863 assert!(edit_result.is_ok());
864
865 // Wait for any async operations (e.g. formatting) to complete
866 cx.executor().run_until_parked();
867
868 // Read the file to verify trailing whitespace was removed automatically
869 assert_eq!(
870 // Ignore carriage returns on Windows
871 fs.load(path!("/root/src/main.rs").as_ref())
872 .await
873 .unwrap()
874 .replace("\r\n", "\n"),
875 "fn main() {\n println!(\"Hello!\");\n}\n",
876 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
877 );
878
879 // Next, test with remove_trailing_whitespace_on_save disabled
880 cx.update(|cx| {
881 SettingsStore::update_global(cx, |store, cx| {
882 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
883 cx,
884 |settings| {
885 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
886 },
887 );
888 });
889 });
890
891 // Stream edits again with trailing whitespace
892 let edit_result = {
893 let edit_task = cx.update(|cx| {
894 let input = EditFileToolInput {
895 display_description: "Update main function".into(),
896 path: "root/src/main.rs".into(),
897 mode: EditFileMode::Overwrite,
898 };
899 Arc::new(EditFileTool {
900 thread: thread.clone(),
901 })
902 .run(input, ToolCallEventStream::test().0, cx)
903 });
904
905 // Stream the content with trailing whitespace
906 cx.executor().run_until_parked();
907 model.send_last_completion_stream_text_chunk(
908 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
909 );
910 model.end_last_completion_stream();
911
912 edit_task.await
913 };
914 assert!(edit_result.is_ok());
915
916 // Wait for any async operations (e.g. formatting) to complete
917 cx.executor().run_until_parked();
918
919 // Verify the file still has trailing whitespace
920 // Read the file again - it should still have trailing whitespace
921 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
922 assert_eq!(
923 // Ignore carriage returns on Windows
924 final_content.replace("\r\n", "\n"),
925 CONTENT_WITH_TRAILING_WHITESPACE,
926 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
927 );
928 }
929
930 #[gpui::test]
931 async fn test_authorize(cx: &mut TestAppContext) {
932 init_test(cx);
933 let fs = project::FakeFs::new(cx.executor());
934 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
935 let context_server_registry =
936 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
937 let action_log = cx.new(|_| ActionLog::new(project.clone()));
938 let model = Arc::new(FakeLanguageModel::default());
939 let thread = cx.new(|cx| {
940 Thread::new(
941 project,
942 Rc::default(),
943 context_server_registry,
944 action_log.clone(),
945 Templates::new(),
946 model.clone(),
947 cx,
948 )
949 });
950 let tool = Arc::new(EditFileTool { thread });
951 fs.insert_tree("/root", json!({})).await;
952
953 // Test 1: Path with .zed component should require confirmation
954 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
955 let _auth = cx.update(|cx| {
956 tool.authorize(
957 &EditFileToolInput {
958 display_description: "test 1".into(),
959 path: ".zed/settings.json".into(),
960 mode: EditFileMode::Edit,
961 },
962 &stream_tx,
963 cx,
964 )
965 });
966
967 let event = stream_rx.expect_authorization().await;
968 assert_eq!(event.tool_call.title, "test 1 (local settings)");
969
970 // Test 2: Path outside project should require confirmation
971 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
972 let _auth = cx.update(|cx| {
973 tool.authorize(
974 &EditFileToolInput {
975 display_description: "test 2".into(),
976 path: "/etc/hosts".into(),
977 mode: EditFileMode::Edit,
978 },
979 &stream_tx,
980 cx,
981 )
982 });
983
984 let event = stream_rx.expect_authorization().await;
985 assert_eq!(event.tool_call.title, "test 2");
986
987 // Test 3: Relative path without .zed should not require confirmation
988 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
989 cx.update(|cx| {
990 tool.authorize(
991 &EditFileToolInput {
992 display_description: "test 3".into(),
993 path: "root/src/main.rs".into(),
994 mode: EditFileMode::Edit,
995 },
996 &stream_tx,
997 cx,
998 )
999 })
1000 .await
1001 .unwrap();
1002 assert!(stream_rx.try_next().is_err());
1003
1004 // Test 4: Path with .zed in the middle should require confirmation
1005 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1006 let _auth = cx.update(|cx| {
1007 tool.authorize(
1008 &EditFileToolInput {
1009 display_description: "test 4".into(),
1010 path: "root/.zed/tasks.json".into(),
1011 mode: EditFileMode::Edit,
1012 },
1013 &stream_tx,
1014 cx,
1015 )
1016 });
1017 let event = stream_rx.expect_authorization().await;
1018 assert_eq!(event.tool_call.title, "test 4 (local settings)");
1019
1020 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1021 cx.update(|cx| {
1022 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1023 settings.always_allow_tool_actions = true;
1024 agent_settings::AgentSettings::override_global(settings, cx);
1025 });
1026
1027 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1028 cx.update(|cx| {
1029 tool.authorize(
1030 &EditFileToolInput {
1031 display_description: "test 5.1".into(),
1032 path: ".zed/settings.json".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 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1044 cx.update(|cx| {
1045 tool.authorize(
1046 &EditFileToolInput {
1047 display_description: "test 5.2".into(),
1048 path: "/etc/hosts".into(),
1049 mode: EditFileMode::Edit,
1050 },
1051 &stream_tx,
1052 cx,
1053 )
1054 })
1055 .await
1056 .unwrap();
1057 assert!(stream_rx.try_next().is_err());
1058 }
1059
1060 #[gpui::test]
1061 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1062 init_test(cx);
1063 let fs = project::FakeFs::new(cx.executor());
1064 fs.insert_tree("/project", json!({})).await;
1065 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1066 let context_server_registry =
1067 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1068 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1069 let model = Arc::new(FakeLanguageModel::default());
1070 let thread = cx.new(|cx| {
1071 Thread::new(
1072 project,
1073 Rc::default(),
1074 context_server_registry,
1075 action_log.clone(),
1076 Templates::new(),
1077 model.clone(),
1078 cx,
1079 )
1080 });
1081 let tool = Arc::new(EditFileTool { thread });
1082
1083 // Test global config paths - these should require confirmation if they exist and are outside the project
1084 let test_cases = vec![
1085 (
1086 "/etc/hosts",
1087 true,
1088 "System file should require confirmation",
1089 ),
1090 (
1091 "/usr/local/bin/script",
1092 true,
1093 "System bin file should require confirmation",
1094 ),
1095 (
1096 "project/normal_file.rs",
1097 false,
1098 "Normal project file should not require confirmation",
1099 ),
1100 ];
1101
1102 for (path, should_confirm, description) in test_cases {
1103 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1104 let auth = cx.update(|cx| {
1105 tool.authorize(
1106 &EditFileToolInput {
1107 display_description: "Edit file".into(),
1108 path: path.into(),
1109 mode: EditFileMode::Edit,
1110 },
1111 &stream_tx,
1112 cx,
1113 )
1114 });
1115
1116 if should_confirm {
1117 stream_rx.expect_authorization().await;
1118 } else {
1119 auth.await.unwrap();
1120 assert!(
1121 stream_rx.try_next().is_err(),
1122 "Failed for case: {} - path: {} - expected no confirmation but got one",
1123 description,
1124 path
1125 );
1126 }
1127 }
1128 }
1129
1130 #[gpui::test]
1131 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1132 init_test(cx);
1133 let fs = project::FakeFs::new(cx.executor());
1134
1135 // Create multiple worktree directories
1136 fs.insert_tree(
1137 "/workspace/frontend",
1138 json!({
1139 "src": {
1140 "main.js": "console.log('frontend');"
1141 }
1142 }),
1143 )
1144 .await;
1145 fs.insert_tree(
1146 "/workspace/backend",
1147 json!({
1148 "src": {
1149 "main.rs": "fn main() {}"
1150 }
1151 }),
1152 )
1153 .await;
1154 fs.insert_tree(
1155 "/workspace/shared",
1156 json!({
1157 ".zed": {
1158 "settings.json": "{}"
1159 }
1160 }),
1161 )
1162 .await;
1163
1164 // Create project with multiple worktrees
1165 let project = Project::test(
1166 fs.clone(),
1167 [
1168 path!("/workspace/frontend").as_ref(),
1169 path!("/workspace/backend").as_ref(),
1170 path!("/workspace/shared").as_ref(),
1171 ],
1172 cx,
1173 )
1174 .await;
1175
1176 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1177 let context_server_registry =
1178 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1179 let model = Arc::new(FakeLanguageModel::default());
1180 let thread = cx.new(|cx| {
1181 Thread::new(
1182 project.clone(),
1183 Rc::default(),
1184 context_server_registry.clone(),
1185 action_log.clone(),
1186 Templates::new(),
1187 model.clone(),
1188 cx,
1189 )
1190 });
1191 let tool = Arc::new(EditFileTool { thread });
1192
1193 // Test files in different worktrees
1194 let test_cases = vec![
1195 ("frontend/src/main.js", false, "File in first worktree"),
1196 ("backend/src/main.rs", false, "File in second worktree"),
1197 (
1198 "shared/.zed/settings.json",
1199 true,
1200 ".zed file in third worktree",
1201 ),
1202 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1203 (
1204 "../outside/file.txt",
1205 true,
1206 "Relative path outside worktrees",
1207 ),
1208 ];
1209
1210 for (path, should_confirm, description) in test_cases {
1211 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1212 let auth = cx.update(|cx| {
1213 tool.authorize(
1214 &EditFileToolInput {
1215 display_description: "Edit file".into(),
1216 path: path.into(),
1217 mode: EditFileMode::Edit,
1218 },
1219 &stream_tx,
1220 cx,
1221 )
1222 });
1223
1224 if should_confirm {
1225 stream_rx.expect_authorization().await;
1226 } else {
1227 auth.await.unwrap();
1228 assert!(
1229 stream_rx.try_next().is_err(),
1230 "Failed for case: {} - path: {} - expected no confirmation but got one",
1231 description,
1232 path
1233 );
1234 }
1235 }
1236 }
1237
1238 #[gpui::test]
1239 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1240 init_test(cx);
1241 let fs = project::FakeFs::new(cx.executor());
1242 fs.insert_tree(
1243 "/project",
1244 json!({
1245 ".zed": {
1246 "settings.json": "{}"
1247 },
1248 "src": {
1249 ".zed": {
1250 "local.json": "{}"
1251 }
1252 }
1253 }),
1254 )
1255 .await;
1256 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1257 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1258 let context_server_registry =
1259 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1260 let model = Arc::new(FakeLanguageModel::default());
1261 let thread = cx.new(|cx| {
1262 Thread::new(
1263 project.clone(),
1264 Rc::default(),
1265 context_server_registry.clone(),
1266 action_log.clone(),
1267 Templates::new(),
1268 model.clone(),
1269 cx,
1270 )
1271 });
1272 let tool = Arc::new(EditFileTool { thread });
1273
1274 // Test edge cases
1275 let test_cases = vec![
1276 // Empty path - find_project_path returns Some for empty paths
1277 ("", false, "Empty path is treated as project root"),
1278 // Root directory
1279 ("/", true, "Root directory should be outside project"),
1280 // Parent directory references - find_project_path resolves these
1281 (
1282 "project/../other",
1283 false,
1284 "Path with .. is resolved by find_project_path",
1285 ),
1286 (
1287 "project/./src/file.rs",
1288 false,
1289 "Path with . should work normally",
1290 ),
1291 // Windows-style paths (if on Windows)
1292 #[cfg(target_os = "windows")]
1293 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1294 #[cfg(target_os = "windows")]
1295 ("project\\src\\main.rs", false, "Windows-style project path"),
1296 ];
1297
1298 for (path, should_confirm, description) in test_cases {
1299 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1300 let auth = cx.update(|cx| {
1301 tool.authorize(
1302 &EditFileToolInput {
1303 display_description: "Edit file".into(),
1304 path: path.into(),
1305 mode: EditFileMode::Edit,
1306 },
1307 &stream_tx,
1308 cx,
1309 )
1310 });
1311
1312 if should_confirm {
1313 stream_rx.expect_authorization().await;
1314 } else {
1315 auth.await.unwrap();
1316 assert!(
1317 stream_rx.try_next().is_err(),
1318 "Failed for case: {} - path: {} - expected no confirmation but got one",
1319 description,
1320 path
1321 );
1322 }
1323 }
1324 }
1325
1326 #[gpui::test]
1327 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1328 init_test(cx);
1329 let fs = project::FakeFs::new(cx.executor());
1330 fs.insert_tree(
1331 "/project",
1332 json!({
1333 "existing.txt": "content",
1334 ".zed": {
1335 "settings.json": "{}"
1336 }
1337 }),
1338 )
1339 .await;
1340 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1341 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1342 let context_server_registry =
1343 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1344 let model = Arc::new(FakeLanguageModel::default());
1345 let thread = cx.new(|cx| {
1346 Thread::new(
1347 project.clone(),
1348 Rc::default(),
1349 context_server_registry.clone(),
1350 action_log.clone(),
1351 Templates::new(),
1352 model.clone(),
1353 cx,
1354 )
1355 });
1356 let tool = Arc::new(EditFileTool { thread });
1357
1358 // Test different EditFileMode values
1359 let modes = vec![
1360 EditFileMode::Edit,
1361 EditFileMode::Create,
1362 EditFileMode::Overwrite,
1363 ];
1364
1365 for mode in modes {
1366 // Test .zed path with different modes
1367 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1368 let _auth = cx.update(|cx| {
1369 tool.authorize(
1370 &EditFileToolInput {
1371 display_description: "Edit settings".into(),
1372 path: "project/.zed/settings.json".into(),
1373 mode: mode.clone(),
1374 },
1375 &stream_tx,
1376 cx,
1377 )
1378 });
1379
1380 stream_rx.expect_authorization().await;
1381
1382 // Test outside path with different modes
1383 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1384 let _auth = cx.update(|cx| {
1385 tool.authorize(
1386 &EditFileToolInput {
1387 display_description: "Edit file".into(),
1388 path: "/outside/file.txt".into(),
1389 mode: mode.clone(),
1390 },
1391 &stream_tx,
1392 cx,
1393 )
1394 });
1395
1396 stream_rx.expect_authorization().await;
1397
1398 // Test normal path with different modes
1399 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1400 cx.update(|cx| {
1401 tool.authorize(
1402 &EditFileToolInput {
1403 display_description: "Edit file".into(),
1404 path: "project/normal.txt".into(),
1405 mode: mode.clone(),
1406 },
1407 &stream_tx,
1408 cx,
1409 )
1410 })
1411 .await
1412 .unwrap();
1413 assert!(stream_rx.try_next().is_err());
1414 }
1415 }
1416
1417 #[gpui::test]
1418 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1419 init_test(cx);
1420 let fs = project::FakeFs::new(cx.executor());
1421 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1422 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1423 let context_server_registry =
1424 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1425 let model = Arc::new(FakeLanguageModel::default());
1426 let thread = cx.new(|cx| {
1427 Thread::new(
1428 project.clone(),
1429 Rc::default(),
1430 context_server_registry,
1431 action_log.clone(),
1432 Templates::new(),
1433 model.clone(),
1434 cx,
1435 )
1436 });
1437 let tool = Arc::new(EditFileTool { thread });
1438
1439 assert_eq!(
1440 tool.initial_title(Err(json!({
1441 "path": "src/main.rs",
1442 "display_description": "",
1443 "old_string": "old code",
1444 "new_string": "new code"
1445 }))),
1446 "src/main.rs"
1447 );
1448 assert_eq!(
1449 tool.initial_title(Err(json!({
1450 "path": "",
1451 "display_description": "Fix error handling",
1452 "old_string": "old code",
1453 "new_string": "new code"
1454 }))),
1455 "Fix error handling"
1456 );
1457 assert_eq!(
1458 tool.initial_title(Err(json!({
1459 "path": "src/main.rs",
1460 "display_description": "Fix error handling",
1461 "old_string": "old code",
1462 "new_string": "new code"
1463 }))),
1464 "Fix error handling"
1465 );
1466 assert_eq!(
1467 tool.initial_title(Err(json!({
1468 "path": "",
1469 "display_description": "",
1470 "old_string": "old code",
1471 "new_string": "new code"
1472 }))),
1473 DEFAULT_UI_TEXT
1474 );
1475 assert_eq!(
1476 tool.initial_title(Err(serde_json::Value::Null)),
1477 DEFAULT_UI_TEXT
1478 );
1479 }
1480
1481 fn init_test(cx: &mut TestAppContext) {
1482 cx.update(|cx| {
1483 let settings_store = SettingsStore::test(cx);
1484 cx.set_global(settings_store);
1485 language::init(cx);
1486 TelemetrySettings::register(cx);
1487 agent_settings::AgentSettings::register(cx);
1488 Project::init_settings(cx);
1489 });
1490 }
1491}