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