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