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