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