1use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
2use super::save_file_tool::SaveFileTool;
3use super::tool_permissions::authorize_file_edit;
4use crate::{
5 AgentTool, Templates, Thread, ToolCallEventStream,
6 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
7};
8use acp_thread::Diff;
9use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10use anyhow::{Context as _, Result, anyhow};
11use cloud_llm_client::CompletionIntent;
12use collections::HashSet;
13use futures::{FutureExt as _, StreamExt as _};
14use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
15use indoc::formatdoc;
16use language::language_settings::{self, FormatOnSave};
17use language::{LanguageRegistry, ToPoint};
18use language_model::LanguageModelToolResultContent;
19use project::lsp_store::{FormatTrigger, LspFormatTarget};
20use project::{Project, ProjectPath};
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24use std::sync::Arc;
25use ui::SharedString;
26use util::ResultExt;
27use util::rel_path::RelPath;
28
29const DEFAULT_UI_TEXT: &str = "Editing file";
30
31/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.
32///
33/// Before using this tool:
34///
35/// 1. Use the `read_file` tool to understand the file's contents and context
36///
37/// 2. Verify the directory path is correct (only applicable when creating new files):
38/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
39#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
40pub struct EditFileToolInput {
41 /// 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.
42 ///
43 /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
44 ///
45 /// NEVER mention the file path in this description.
46 ///
47 /// <example>Fix API endpoint URLs</example>
48 /// <example>Update copyright year in `page_footer`</example>
49 ///
50 /// Make sure to include this field before all the others in the input object so that we can display it immediately.
51 pub display_description: String,
52
53 /// The full path of the file to create or modify in the project.
54 ///
55 /// WARNING: When specifying which file path need changing, you MUST 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 would be ambiguous and the call would fail!
65 /// </example>
66 ///
67 /// <example>
68 /// `frontend/db.js`
69 /// </example>
70 pub path: PathBuf,
71 /// The mode of operation on the file. Possible values:
72 /// - 'edit': Make granular edits to an existing file.
73 /// - 'create': Create a new file if it doesn't exist.
74 /// - 'overwrite': Replace the entire contents of an existing file.
75 ///
76 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
77 pub mode: EditFileMode,
78}
79
80#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
81struct EditFileToolPartialInput {
82 #[serde(default)]
83 path: String,
84 #[serde(default)]
85 display_description: String,
86}
87
88#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
89#[serde(rename_all = "lowercase")]
90#[schemars(inline)]
91pub enum EditFileMode {
92 Edit,
93 Create,
94 Overwrite,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct EditFileToolOutput {
99 #[serde(alias = "original_path")]
100 input_path: PathBuf,
101 new_text: String,
102 old_text: Arc<String>,
103 #[serde(default)]
104 diff: String,
105 #[serde(alias = "raw_output")]
106 edit_agent_output: EditAgentOutput,
107}
108
109impl From<EditFileToolOutput> for LanguageModelToolResultContent {
110 fn from(output: EditFileToolOutput) -> Self {
111 if output.diff.is_empty() {
112 "No edits were made.".into()
113 } else {
114 format!(
115 "Edited {}:\n\n```diff\n{}\n```",
116 output.input_path.display(),
117 output.diff
118 )
119 .into()
120 }
121 }
122}
123
124pub struct EditFileTool {
125 thread: WeakEntity<Thread>,
126 language_registry: Arc<LanguageRegistry>,
127 project: Entity<Project>,
128 templates: Arc<Templates>,
129}
130
131impl EditFileTool {
132 pub fn new(
133 project: Entity<Project>,
134 thread: WeakEntity<Thread>,
135 language_registry: Arc<LanguageRegistry>,
136 templates: Arc<Templates>,
137 ) -> Self {
138 Self {
139 project,
140 thread,
141 language_registry,
142 templates,
143 }
144 }
145
146 fn authorize(
147 &self,
148 input: &EditFileToolInput,
149 event_stream: &ToolCallEventStream,
150 cx: &mut App,
151 ) -> Task<Result<()>> {
152 authorize_file_edit(
153 Self::NAME,
154 &input.path,
155 &input.display_description,
156 &self.thread,
157 event_stream,
158 cx,
159 )
160 }
161}
162
163impl AgentTool for EditFileTool {
164 type Input = EditFileToolInput;
165 type Output = EditFileToolOutput;
166
167 const NAME: &'static str = "edit_file";
168
169 fn kind() -> acp::ToolKind {
170 acp::ToolKind::Edit
171 }
172
173 fn initial_title(
174 &self,
175 input: Result<Self::Input, serde_json::Value>,
176 cx: &mut App,
177 ) -> SharedString {
178 match input {
179 Ok(input) => self
180 .project
181 .read(cx)
182 .find_project_path(&input.path, cx)
183 .and_then(|project_path| {
184 self.project
185 .read(cx)
186 .short_full_path_for_project_path(&project_path, cx)
187 })
188 .unwrap_or(input.path.to_string_lossy().into_owned())
189 .into(),
190 Err(raw_input) => {
191 if let Some(input) =
192 serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
193 {
194 let path = input.path.trim();
195 if !path.is_empty() {
196 return self
197 .project
198 .read(cx)
199 .find_project_path(&input.path, cx)
200 .and_then(|project_path| {
201 self.project
202 .read(cx)
203 .short_full_path_for_project_path(&project_path, cx)
204 })
205 .unwrap_or(input.path)
206 .into();
207 }
208
209 let description = input.display_description.trim();
210 if !description.is_empty() {
211 return description.to_string().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(
239 ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
240 );
241 }
242
243 let authorize = self.authorize(&input, &event_stream, cx);
244 cx.spawn(async move |cx: &mut AsyncApp| {
245 authorize.await?;
246
247 let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
248 let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
249 (request, thread.model().cloned(), thread.action_log().clone())
250 })?;
251 let request = request?;
252 let model = model.context("No language model configured")?;
253
254 let edit_format = EditFormat::from_model(model.clone())?;
255 let edit_agent = EditAgent::new(
256 model,
257 project.clone(),
258 action_log.clone(),
259 self.templates.clone(),
260 edit_format,
261 );
262
263 let buffer = project
264 .update(cx, |project, cx| {
265 project.open_buffer(project_path.clone(), cx)
266 })
267 .await?;
268
269 // Check if the file has been modified since the agent last read it
270 if let Some(abs_path) = abs_path.as_ref() {
271 let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
272 let last_read = thread.file_read_times.get(abs_path).copied();
273 let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
274 let dirty = buffer.read(cx).is_dirty();
275 let has_save = thread.has_tool(SaveFileTool::NAME);
276 let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
277 (last_read, current, dirty, has_save, has_restore)
278 })?;
279
280 // Check for unsaved changes first - these indicate modifications we don't know about
281 if is_dirty {
282 let message = match (has_save_tool, has_restore_tool) {
283 (true, true) => {
284 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
285 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
286 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
287 }
288 (true, false) => {
289 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
290 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
291 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
292 }
293 (false, true) => {
294 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
295 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
296 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
297 }
298 (false, false) => {
299 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
300 then ask them to save or revert the file manually and inform you when it's ok to proceed."
301 }
302 };
303 anyhow::bail!("{}", message);
304 }
305
306 // Check if the file was modified on disk since we last read it
307 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
308 // MTime can be unreliable for comparisons, so our newtype intentionally
309 // doesn't support comparing them. If the mtime at all different
310 // (which could be because of a modification or because e.g. system clock changed),
311 // we pessimistically assume it was modified.
312 if current != last_read {
313 anyhow::bail!(
314 "The file {} has been modified since you last read it. \
315 Please read the file again to get the current state before editing it.",
316 input.path.display()
317 );
318 }
319 }
320 }
321
322 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
323 event_stream.update_diff(diff.clone());
324 let _finalize_diff = util::defer({
325 let diff = diff.downgrade();
326 let mut cx = cx.clone();
327 move || {
328 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
329 }
330 });
331
332 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
333 let old_text = cx
334 .background_spawn({
335 let old_snapshot = old_snapshot.clone();
336 async move { Arc::new(old_snapshot.text()) }
337 })
338 .await;
339
340 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
341 edit_agent.edit(
342 buffer.clone(),
343 input.display_description.clone(),
344 &request,
345 cx,
346 )
347 } else {
348 edit_agent.overwrite(
349 buffer.clone(),
350 input.display_description.clone(),
351 &request,
352 cx,
353 )
354 };
355
356 let mut hallucinated_old_text = false;
357 let mut ambiguous_ranges = Vec::new();
358 let mut emitted_location = false;
359 loop {
360 let event = futures::select! {
361 event = events.next().fuse() => match event {
362 Some(event) => event,
363 None => break,
364 },
365 _ = event_stream.cancelled_by_user().fuse() => {
366 anyhow::bail!("Edit cancelled by user");
367 }
368 };
369 match event {
370 EditAgentOutputEvent::Edited(range) => {
371 if !emitted_location {
372 let line = Some(buffer.update(cx, |buffer, _cx| {
373 range.start.to_point(&buffer.snapshot()).row
374 }));
375 if let Some(abs_path) = abs_path.clone() {
376 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
377 }
378 emitted_location = true;
379 }
380 },
381 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
382 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
383 EditAgentOutputEvent::ResolvingEditRange(range) => {
384 diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
385 // if !emitted_location {
386 // let line = buffer.update(cx, |buffer, _cx| {
387 // range.start.to_point(&buffer.snapshot()).row
388 // }).ok();
389 // if let Some(abs_path) = abs_path.clone() {
390 // event_stream.update_fields(ToolCallUpdateFields {
391 // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
392 // ..Default::default()
393 // });
394 // }
395 // }
396 }
397 }
398 }
399
400 let edit_agent_output = output.await?;
401
402 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
403 let settings = language_settings::language_settings(
404 buffer.language().map(|l| l.name()),
405 buffer.file(),
406 cx,
407 );
408 settings.format_on_save != FormatOnSave::Off
409 });
410
411 if format_on_save_enabled {
412 action_log.update(cx, |log, cx| {
413 log.buffer_edited(buffer.clone(), cx);
414 });
415
416 let format_task = project.update(cx, |project, cx| {
417 project.format(
418 HashSet::from_iter([buffer.clone()]),
419 LspFormatTarget::Buffers,
420 false, // Don't push to history since the tool did it.
421 FormatTrigger::Save,
422 cx,
423 )
424 });
425 format_task.await.log_err();
426 }
427
428 project
429 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
430 .await?;
431
432 action_log.update(cx, |log, cx| {
433 log.buffer_edited(buffer.clone(), cx);
434 });
435
436 // Update the recorded read time after a successful edit so consecutive edits work
437 if let Some(abs_path) = abs_path.as_ref() {
438 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
439 buffer.file().and_then(|file| file.disk_state().mtime())
440 }) {
441 self.thread.update(cx, |thread, _| {
442 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
443 })?;
444 }
445 }
446
447 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
448 let (new_text, unified_diff) = cx
449 .background_spawn({
450 let new_snapshot = new_snapshot.clone();
451 let old_text = old_text.clone();
452 async move {
453 let new_text = new_snapshot.text();
454 let diff = language::unified_diff(&old_text, &new_text);
455 (new_text, diff)
456 }
457 })
458 .await;
459
460 let input_path = input.path.display();
461 if unified_diff.is_empty() {
462 anyhow::ensure!(
463 !hallucinated_old_text,
464 formatdoc! {"
465 Some edits were produced but none of them could be applied.
466 Read the relevant sections of {input_path} again so that
467 I can perform the requested edits.
468 "}
469 );
470 anyhow::ensure!(
471 ambiguous_ranges.is_empty(),
472 {
473 let line_numbers = ambiguous_ranges
474 .iter()
475 .map(|range| range.start.to_string())
476 .collect::<Vec<_>>()
477 .join(", ");
478 formatdoc! {"
479 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
480 relevant sections of {input_path} again and extend <old_text> so
481 that I can perform the requested edits.
482 "}
483 }
484 );
485 }
486
487 Ok(EditFileToolOutput {
488 input_path: input.path,
489 new_text,
490 old_text,
491 diff: unified_diff,
492 edit_agent_output,
493 })
494 })
495 }
496
497 fn replay(
498 &self,
499 _input: Self::Input,
500 output: Self::Output,
501 event_stream: ToolCallEventStream,
502 cx: &mut App,
503 ) -> Result<()> {
504 event_stream.update_diff(cx.new(|cx| {
505 Diff::finalized(
506 output.input_path.to_string_lossy().into_owned(),
507 Some(output.old_text.to_string()),
508 output.new_text,
509 self.language_registry.clone(),
510 cx,
511 )
512 }));
513 Ok(())
514 }
515}
516
517/// Validate that the file path is valid, meaning:
518///
519/// - For `edit` and `overwrite`, the path must point to an existing file.
520/// - For `create`, the file must not already exist, but it's parent dir must exist.
521fn resolve_path(
522 input: &EditFileToolInput,
523 project: Entity<Project>,
524 cx: &mut App,
525) -> Result<ProjectPath> {
526 let project = project.read(cx);
527
528 match input.mode {
529 EditFileMode::Edit | EditFileMode::Overwrite => {
530 let path = project
531 .find_project_path(&input.path, cx)
532 .context("Can't edit file: path not found")?;
533
534 let entry = project
535 .entry_for_path(&path, cx)
536 .context("Can't edit file: path not found")?;
537
538 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
539 Ok(path)
540 }
541
542 EditFileMode::Create => {
543 if let Some(path) = project.find_project_path(&input.path, cx) {
544 anyhow::ensure!(
545 project.entry_for_path(&path, cx).is_none(),
546 "Can't create file: file already exists"
547 );
548 }
549
550 let parent_path = input
551 .path
552 .parent()
553 .context("Can't create file: incorrect path")?;
554
555 let parent_project_path = project.find_project_path(&parent_path, cx);
556
557 let parent_entry = parent_project_path
558 .as_ref()
559 .and_then(|path| project.entry_for_path(path, cx))
560 .context("Can't create file: parent directory doesn't exist")?;
561
562 anyhow::ensure!(
563 parent_entry.is_dir(),
564 "Can't create file: parent is not a directory"
565 );
566
567 let file_name = input
568 .path
569 .file_name()
570 .and_then(|file_name| file_name.to_str())
571 .and_then(|file_name| RelPath::unix(file_name).ok())
572 .context("Can't create file: invalid filename")?;
573
574 let new_file_path = parent_project_path.map(|parent| ProjectPath {
575 path: parent.path.join(file_name),
576 ..parent
577 });
578
579 new_file_path.context("Can't create file")
580 }
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::tools::tool_permissions::{SensitiveSettingsKind, sensitive_settings_kind};
588 use crate::{ContextServerRegistry, Templates};
589 use fs::Fs as _;
590 use gpui::{TestAppContext, UpdateGlobal};
591 use language_model::fake_provider::FakeLanguageModel;
592 use prompt_store::ProjectContext;
593 use serde_json::json;
594 use settings::Settings;
595 use settings::SettingsStore;
596 use util::{path, rel_path::rel_path};
597
598 #[gpui::test]
599 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
600 init_test(cx);
601
602 let fs = project::FakeFs::new(cx.executor());
603 fs.insert_tree("/root", json!({})).await;
604 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
605 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
606 let context_server_registry =
607 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
608 let model = Arc::new(FakeLanguageModel::default());
609 let thread = cx.new(|cx| {
610 Thread::new(
611 project.clone(),
612 cx.new(|_cx| ProjectContext::default()),
613 context_server_registry,
614 Templates::new(),
615 Some(model),
616 cx,
617 )
618 });
619 let result = cx
620 .update(|cx| {
621 let input = EditFileToolInput {
622 display_description: "Some edit".into(),
623 path: "root/nonexistent_file.txt".into(),
624 mode: EditFileMode::Edit,
625 };
626 Arc::new(EditFileTool::new(
627 project,
628 thread.downgrade(),
629 language_registry,
630 Templates::new(),
631 ))
632 .run(input, ToolCallEventStream::test().0, cx)
633 })
634 .await;
635 assert_eq!(
636 result.unwrap_err().to_string(),
637 "Can't edit file: path not found"
638 );
639 }
640
641 #[gpui::test]
642 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
643 let mode = &EditFileMode::Create;
644
645 let result = test_resolve_path(mode, "root/new.txt", cx);
646 assert_resolved_path_eq(result.await, rel_path("new.txt"));
647
648 let result = test_resolve_path(mode, "new.txt", cx);
649 assert_resolved_path_eq(result.await, rel_path("new.txt"));
650
651 let result = test_resolve_path(mode, "dir/new.txt", cx);
652 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
653
654 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
655 assert_eq!(
656 result.await.unwrap_err().to_string(),
657 "Can't create file: file already exists"
658 );
659
660 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
661 assert_eq!(
662 result.await.unwrap_err().to_string(),
663 "Can't create file: parent directory doesn't exist"
664 );
665 }
666
667 #[gpui::test]
668 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
669 let mode = &EditFileMode::Edit;
670
671 let path_with_root = "root/dir/subdir/existing.txt";
672 let path_without_root = "dir/subdir/existing.txt";
673 let result = test_resolve_path(mode, path_with_root, cx);
674 assert_resolved_path_eq(result.await, rel_path(path_without_root));
675
676 let result = test_resolve_path(mode, path_without_root, cx);
677 assert_resolved_path_eq(result.await, rel_path(path_without_root));
678
679 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
680 assert_eq!(
681 result.await.unwrap_err().to_string(),
682 "Can't edit file: path not found"
683 );
684
685 let result = test_resolve_path(mode, "root/dir", cx);
686 assert_eq!(
687 result.await.unwrap_err().to_string(),
688 "Can't edit file: path is a directory"
689 );
690 }
691
692 async fn test_resolve_path(
693 mode: &EditFileMode,
694 path: &str,
695 cx: &mut TestAppContext,
696 ) -> anyhow::Result<ProjectPath> {
697 init_test(cx);
698
699 let fs = project::FakeFs::new(cx.executor());
700 fs.insert_tree(
701 "/root",
702 json!({
703 "dir": {
704 "subdir": {
705 "existing.txt": "hello"
706 }
707 }
708 }),
709 )
710 .await;
711 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
712
713 let input = EditFileToolInput {
714 display_description: "Some edit".into(),
715 path: path.into(),
716 mode: mode.clone(),
717 };
718
719 cx.update(|cx| resolve_path(&input, project, cx))
720 }
721
722 #[track_caller]
723 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
724 let actual = path.expect("Should return valid path").path;
725 assert_eq!(actual.as_ref(), expected);
726 }
727
728 #[gpui::test]
729 async fn test_format_on_save(cx: &mut TestAppContext) {
730 init_test(cx);
731
732 let fs = project::FakeFs::new(cx.executor());
733 fs.insert_tree("/root", json!({"src": {}})).await;
734
735 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
736
737 // Set up a Rust language with LSP formatting support
738 let rust_language = Arc::new(language::Language::new(
739 language::LanguageConfig {
740 name: "Rust".into(),
741 matcher: language::LanguageMatcher {
742 path_suffixes: vec!["rs".to_string()],
743 ..Default::default()
744 },
745 ..Default::default()
746 },
747 None,
748 ));
749
750 // Register the language and fake LSP
751 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
752 language_registry.add(rust_language);
753
754 let mut fake_language_servers = language_registry.register_fake_lsp(
755 "Rust",
756 language::FakeLspAdapter {
757 capabilities: lsp::ServerCapabilities {
758 document_formatting_provider: Some(lsp::OneOf::Left(true)),
759 ..Default::default()
760 },
761 ..Default::default()
762 },
763 );
764
765 // Create the file
766 fs.save(
767 path!("/root/src/main.rs").as_ref(),
768 &"initial content".into(),
769 language::LineEnding::Unix,
770 )
771 .await
772 .unwrap();
773
774 // Open the buffer to trigger LSP initialization
775 let buffer = project
776 .update(cx, |project, cx| {
777 project.open_local_buffer(path!("/root/src/main.rs"), cx)
778 })
779 .await
780 .unwrap();
781
782 // Register the buffer with language servers
783 let _handle = project.update(cx, |project, cx| {
784 project.register_buffer_with_language_servers(&buffer, cx)
785 });
786
787 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
788 const FORMATTED_CONTENT: &str =
789 "This file was formatted by the fake formatter in the test.\n";
790
791 // Get the fake language server and set up formatting handler
792 let fake_language_server = fake_language_servers.next().await.unwrap();
793 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
794 |_, _| async move {
795 Ok(Some(vec![lsp::TextEdit {
796 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
797 new_text: FORMATTED_CONTENT.to_string(),
798 }]))
799 }
800 });
801
802 let context_server_registry =
803 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
804 let model = Arc::new(FakeLanguageModel::default());
805 let thread = cx.new(|cx| {
806 Thread::new(
807 project.clone(),
808 cx.new(|_cx| ProjectContext::default()),
809 context_server_registry,
810 Templates::new(),
811 Some(model.clone()),
812 cx,
813 )
814 });
815
816 // First, test with format_on_save enabled
817 cx.update(|cx| {
818 SettingsStore::update_global(cx, |store, cx| {
819 store.update_user_settings(cx, |settings| {
820 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
821 settings.project.all_languages.defaults.formatter =
822 Some(language::language_settings::FormatterList::default());
823 });
824 });
825 });
826
827 // Have the model stream unformatted content
828 let edit_result = {
829 let edit_task = cx.update(|cx| {
830 let input = EditFileToolInput {
831 display_description: "Create main function".into(),
832 path: "root/src/main.rs".into(),
833 mode: EditFileMode::Overwrite,
834 };
835 Arc::new(EditFileTool::new(
836 project.clone(),
837 thread.downgrade(),
838 language_registry.clone(),
839 Templates::new(),
840 ))
841 .run(input, ToolCallEventStream::test().0, cx)
842 });
843
844 // Stream the unformatted content
845 cx.executor().run_until_parked();
846 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
847 model.end_last_completion_stream();
848
849 edit_task.await
850 };
851 assert!(edit_result.is_ok());
852
853 // Wait for any async operations (e.g. formatting) to complete
854 cx.executor().run_until_parked();
855
856 // Read the file to verify it was formatted automatically
857 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
858 assert_eq!(
859 // Ignore carriage returns on Windows
860 new_content.replace("\r\n", "\n"),
861 FORMATTED_CONTENT,
862 "Code should be formatted when format_on_save is enabled"
863 );
864
865 let stale_buffer_count = thread
866 .read_with(cx, |thread, _cx| thread.action_log.clone())
867 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
868
869 assert_eq!(
870 stale_buffer_count, 0,
871 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
872 This causes the agent to think the file was modified externally when it was just formatted.",
873 stale_buffer_count
874 );
875
876 // Next, test with format_on_save disabled
877 cx.update(|cx| {
878 SettingsStore::update_global(cx, |store, cx| {
879 store.update_user_settings(cx, |settings| {
880 settings.project.all_languages.defaults.format_on_save =
881 Some(FormatOnSave::Off);
882 });
883 });
884 });
885
886 // Stream unformatted edits again
887 let edit_result = {
888 let edit_task = cx.update(|cx| {
889 let input = EditFileToolInput {
890 display_description: "Update main function".into(),
891 path: "root/src/main.rs".into(),
892 mode: EditFileMode::Overwrite,
893 };
894 Arc::new(EditFileTool::new(
895 project.clone(),
896 thread.downgrade(),
897 language_registry,
898 Templates::new(),
899 ))
900 .run(input, ToolCallEventStream::test().0, cx)
901 });
902
903 // Stream the unformatted content
904 cx.executor().run_until_parked();
905 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
906 model.end_last_completion_stream();
907
908 edit_task.await
909 };
910 assert!(edit_result.is_ok());
911
912 // Wait for any async operations (e.g. formatting) to complete
913 cx.executor().run_until_parked();
914
915 // Verify the file was not formatted
916 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
917 assert_eq!(
918 // Ignore carriage returns on Windows
919 new_content.replace("\r\n", "\n"),
920 UNFORMATTED_CONTENT,
921 "Code should not be formatted when format_on_save is disabled"
922 );
923 }
924
925 #[gpui::test]
926 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
927 init_test(cx);
928
929 let fs = project::FakeFs::new(cx.executor());
930 fs.insert_tree("/root", json!({"src": {}})).await;
931
932 // Create a simple file with trailing whitespace
933 fs.save(
934 path!("/root/src/main.rs").as_ref(),
935 &"initial content".into(),
936 language::LineEnding::Unix,
937 )
938 .await
939 .unwrap();
940
941 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
942 let context_server_registry =
943 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
944 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
945 let model = Arc::new(FakeLanguageModel::default());
946 let thread = cx.new(|cx| {
947 Thread::new(
948 project.clone(),
949 cx.new(|_cx| ProjectContext::default()),
950 context_server_registry,
951 Templates::new(),
952 Some(model.clone()),
953 cx,
954 )
955 });
956
957 // First, test with remove_trailing_whitespace_on_save enabled
958 cx.update(|cx| {
959 SettingsStore::update_global(cx, |store, cx| {
960 store.update_user_settings(cx, |settings| {
961 settings
962 .project
963 .all_languages
964 .defaults
965 .remove_trailing_whitespace_on_save = Some(true);
966 });
967 });
968 });
969
970 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
971 "fn main() { \n println!(\"Hello!\"); \n}\n";
972
973 // Have the model stream content that contains trailing whitespace
974 let edit_result = {
975 let edit_task = cx.update(|cx| {
976 let input = EditFileToolInput {
977 display_description: "Create main function".into(),
978 path: "root/src/main.rs".into(),
979 mode: EditFileMode::Overwrite,
980 };
981 Arc::new(EditFileTool::new(
982 project.clone(),
983 thread.downgrade(),
984 language_registry.clone(),
985 Templates::new(),
986 ))
987 .run(input, ToolCallEventStream::test().0, cx)
988 });
989
990 // Stream the content with trailing whitespace
991 cx.executor().run_until_parked();
992 model.send_last_completion_stream_text_chunk(
993 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
994 );
995 model.end_last_completion_stream();
996
997 edit_task.await
998 };
999 assert!(edit_result.is_ok());
1000
1001 // Wait for any async operations (e.g. formatting) to complete
1002 cx.executor().run_until_parked();
1003
1004 // Read the file to verify trailing whitespace was removed automatically
1005 assert_eq!(
1006 // Ignore carriage returns on Windows
1007 fs.load(path!("/root/src/main.rs").as_ref())
1008 .await
1009 .unwrap()
1010 .replace("\r\n", "\n"),
1011 "fn main() {\n println!(\"Hello!\");\n}\n",
1012 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1013 );
1014
1015 // Next, test with remove_trailing_whitespace_on_save disabled
1016 cx.update(|cx| {
1017 SettingsStore::update_global(cx, |store, cx| {
1018 store.update_user_settings(cx, |settings| {
1019 settings
1020 .project
1021 .all_languages
1022 .defaults
1023 .remove_trailing_whitespace_on_save = Some(false);
1024 });
1025 });
1026 });
1027
1028 // Stream edits again with trailing whitespace
1029 let edit_result = {
1030 let edit_task = cx.update(|cx| {
1031 let input = EditFileToolInput {
1032 display_description: "Update main function".into(),
1033 path: "root/src/main.rs".into(),
1034 mode: EditFileMode::Overwrite,
1035 };
1036 Arc::new(EditFileTool::new(
1037 project.clone(),
1038 thread.downgrade(),
1039 language_registry,
1040 Templates::new(),
1041 ))
1042 .run(input, ToolCallEventStream::test().0, cx)
1043 });
1044
1045 // Stream the content with trailing whitespace
1046 cx.executor().run_until_parked();
1047 model.send_last_completion_stream_text_chunk(
1048 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1049 );
1050 model.end_last_completion_stream();
1051
1052 edit_task.await
1053 };
1054 assert!(edit_result.is_ok());
1055
1056 // Wait for any async operations (e.g. formatting) to complete
1057 cx.executor().run_until_parked();
1058
1059 // Verify the file still has trailing whitespace
1060 // Read the file again - it should still have trailing whitespace
1061 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1062 assert_eq!(
1063 // Ignore carriage returns on Windows
1064 final_content.replace("\r\n", "\n"),
1065 CONTENT_WITH_TRAILING_WHITESPACE,
1066 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1067 );
1068 }
1069
1070 #[gpui::test]
1071 async fn test_authorize(cx: &mut TestAppContext) {
1072 init_test(cx);
1073 let fs = project::FakeFs::new(cx.executor());
1074 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1075 let context_server_registry =
1076 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1077 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1078 let model = Arc::new(FakeLanguageModel::default());
1079 let thread = cx.new(|cx| {
1080 Thread::new(
1081 project.clone(),
1082 cx.new(|_cx| ProjectContext::default()),
1083 context_server_registry,
1084 Templates::new(),
1085 Some(model.clone()),
1086 cx,
1087 )
1088 });
1089 let tool = Arc::new(EditFileTool::new(
1090 project.clone(),
1091 thread.downgrade(),
1092 language_registry,
1093 Templates::new(),
1094 ));
1095 fs.insert_tree("/root", json!({})).await;
1096
1097 // Test 1: Path with .zed component should require confirmation
1098 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1099 let _auth = cx.update(|cx| {
1100 tool.authorize(
1101 &EditFileToolInput {
1102 display_description: "test 1".into(),
1103 path: ".zed/settings.json".into(),
1104 mode: EditFileMode::Edit,
1105 },
1106 &stream_tx,
1107 cx,
1108 )
1109 });
1110
1111 let event = stream_rx.expect_authorization().await;
1112 assert_eq!(
1113 event.tool_call.fields.title,
1114 Some("test 1 (local settings)".into())
1115 );
1116
1117 // Test 2: Path outside project should require confirmation
1118 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1119 let _auth = cx.update(|cx| {
1120 tool.authorize(
1121 &EditFileToolInput {
1122 display_description: "test 2".into(),
1123 path: "/etc/hosts".into(),
1124 mode: EditFileMode::Edit,
1125 },
1126 &stream_tx,
1127 cx,
1128 )
1129 });
1130
1131 let event = stream_rx.expect_authorization().await;
1132 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1133
1134 // Test 3: Relative path without .zed should not require confirmation
1135 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1136 cx.update(|cx| {
1137 tool.authorize(
1138 &EditFileToolInput {
1139 display_description: "test 3".into(),
1140 path: "root/src/main.rs".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 // Test 4: Path with .zed in the middle should require confirmation
1152 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1153 let _auth = cx.update(|cx| {
1154 tool.authorize(
1155 &EditFileToolInput {
1156 display_description: "test 4".into(),
1157 path: "root/.zed/tasks.json".into(),
1158 mode: EditFileMode::Edit,
1159 },
1160 &stream_tx,
1161 cx,
1162 )
1163 });
1164 let event = stream_rx.expect_authorization().await;
1165 assert_eq!(
1166 event.tool_call.fields.title,
1167 Some("test 4 (local settings)".into())
1168 );
1169
1170 // Test 5: When global default is allow, sensitive and outside-project
1171 // paths still require confirmation
1172 cx.update(|cx| {
1173 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1174 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1175 agent_settings::AgentSettings::override_global(settings, cx);
1176 });
1177
1178 // 5.1: .zed/settings.json is a sensitive path — still prompts
1179 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1180 let _auth = cx.update(|cx| {
1181 tool.authorize(
1182 &EditFileToolInput {
1183 display_description: "test 5.1".into(),
1184 path: ".zed/settings.json".into(),
1185 mode: EditFileMode::Edit,
1186 },
1187 &stream_tx,
1188 cx,
1189 )
1190 });
1191 let event = stream_rx.expect_authorization().await;
1192 assert_eq!(
1193 event.tool_call.fields.title,
1194 Some("test 5.1 (local settings)".into())
1195 );
1196
1197 // 5.2: /etc/hosts is outside the project, but Allow auto-approves
1198 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1199 cx.update(|cx| {
1200 tool.authorize(
1201 &EditFileToolInput {
1202 display_description: "test 5.2".into(),
1203 path: "/etc/hosts".into(),
1204 mode: EditFileMode::Edit,
1205 },
1206 &stream_tx,
1207 cx,
1208 )
1209 })
1210 .await
1211 .unwrap();
1212 assert!(stream_rx.try_next().is_err());
1213
1214 // 5.3: Normal in-project path with allow — no confirmation needed
1215 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1216 cx.update(|cx| {
1217 tool.authorize(
1218 &EditFileToolInput {
1219 display_description: "test 5.3".into(),
1220 path: "root/src/main.rs".into(),
1221 mode: EditFileMode::Edit,
1222 },
1223 &stream_tx,
1224 cx,
1225 )
1226 })
1227 .await
1228 .unwrap();
1229 assert!(stream_rx.try_next().is_err());
1230
1231 // 5.4: With Confirm default, non-project paths still prompt
1232 cx.update(|cx| {
1233 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1234 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
1235 agent_settings::AgentSettings::override_global(settings, cx);
1236 });
1237
1238 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1239 let _auth = cx.update(|cx| {
1240 tool.authorize(
1241 &EditFileToolInput {
1242 display_description: "test 5.4".into(),
1243 path: "/etc/hosts".into(),
1244 mode: EditFileMode::Edit,
1245 },
1246 &stream_tx,
1247 cx,
1248 )
1249 });
1250
1251 let event = stream_rx.expect_authorization().await;
1252 assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
1253 }
1254
1255 #[gpui::test]
1256 async fn test_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) {
1257 init_test(cx);
1258
1259 let fs = project::FakeFs::new(cx.executor());
1260 fs.insert_tree("/root", json!({})).await;
1261 fs.insert_tree("/outside", json!({})).await;
1262 fs.insert_symlink("/root/link", PathBuf::from("/outside"))
1263 .await;
1264
1265 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1266 let context_server_registry =
1267 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1268 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1269 let model = Arc::new(FakeLanguageModel::default());
1270 let thread = cx.new(|cx| {
1271 Thread::new(
1272 project.clone(),
1273 cx.new(|_cx| ProjectContext::default()),
1274 context_server_registry,
1275 Templates::new(),
1276 Some(model),
1277 cx,
1278 )
1279 });
1280 let tool = Arc::new(EditFileTool::new(
1281 project,
1282 thread.downgrade(),
1283 language_registry,
1284 Templates::new(),
1285 ));
1286
1287 cx.update(|cx| {
1288 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1289 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1290 agent_settings::AgentSettings::override_global(settings, cx);
1291 });
1292
1293 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1294 let authorize_task = cx.update(|cx| {
1295 tool.authorize(
1296 &EditFileToolInput {
1297 display_description: "create through symlink".into(),
1298 path: "link/new.txt".into(),
1299 mode: EditFileMode::Create,
1300 },
1301 &stream_tx,
1302 cx,
1303 )
1304 });
1305
1306 let event = stream_rx.expect_authorization().await;
1307 assert!(
1308 event
1309 .tool_call
1310 .fields
1311 .title
1312 .as_deref()
1313 .is_some_and(|title| title.contains("points outside the project")),
1314 "Expected symlink escape authorization for create under external symlink"
1315 );
1316
1317 event
1318 .response
1319 .send(acp::PermissionOptionId::new("allow"))
1320 .unwrap();
1321 authorize_task.await.unwrap();
1322 }
1323
1324 #[gpui::test]
1325 async fn test_edit_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1326 init_test(cx);
1327
1328 let fs = project::FakeFs::new(cx.executor());
1329 fs.insert_tree(
1330 path!("/root"),
1331 json!({
1332 "src": { "main.rs": "fn main() {}" }
1333 }),
1334 )
1335 .await;
1336 fs.insert_tree(
1337 path!("/outside"),
1338 json!({
1339 "config.txt": "old content"
1340 }),
1341 )
1342 .await;
1343 fs.create_symlink(
1344 path!("/root/link_to_external").as_ref(),
1345 PathBuf::from("/outside"),
1346 )
1347 .await
1348 .unwrap();
1349
1350 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1351 cx.executor().run_until_parked();
1352
1353 let language_registry = project.read_with(cx, |project, _| project.languages().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,
1362 Templates::new(),
1363 Some(model),
1364 cx,
1365 )
1366 });
1367 let tool = Arc::new(EditFileTool::new(
1368 project.clone(),
1369 thread.downgrade(),
1370 language_registry,
1371 Templates::new(),
1372 ));
1373
1374 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1375 let _authorize_task = cx.update(|cx| {
1376 tool.authorize(
1377 &EditFileToolInput {
1378 display_description: "edit through symlink".into(),
1379 path: PathBuf::from("link_to_external/config.txt"),
1380 mode: EditFileMode::Edit,
1381 },
1382 &stream_tx,
1383 cx,
1384 )
1385 });
1386
1387 let auth = stream_rx.expect_authorization().await;
1388 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1389 assert!(
1390 title.contains("points outside the project"),
1391 "title should mention symlink escape, got: {title}"
1392 );
1393 }
1394
1395 #[gpui::test]
1396 async fn test_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
1397 init_test(cx);
1398
1399 let fs = project::FakeFs::new(cx.executor());
1400 fs.insert_tree(
1401 path!("/root"),
1402 json!({
1403 "src": { "main.rs": "fn main() {}" }
1404 }),
1405 )
1406 .await;
1407 fs.insert_tree(
1408 path!("/outside"),
1409 json!({
1410 "config.txt": "old content"
1411 }),
1412 )
1413 .await;
1414 fs.create_symlink(
1415 path!("/root/link_to_external").as_ref(),
1416 PathBuf::from("/outside"),
1417 )
1418 .await
1419 .unwrap();
1420
1421 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1422 cx.executor().run_until_parked();
1423
1424 let language_registry = project.read_with(cx, |project, _| project.languages().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,
1433 Templates::new(),
1434 Some(model),
1435 cx,
1436 )
1437 });
1438 let tool = Arc::new(EditFileTool::new(
1439 project.clone(),
1440 thread.downgrade(),
1441 language_registry,
1442 Templates::new(),
1443 ));
1444
1445 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1446 let authorize_task = cx.update(|cx| {
1447 tool.authorize(
1448 &EditFileToolInput {
1449 display_description: "edit through symlink".into(),
1450 path: PathBuf::from("link_to_external/config.txt"),
1451 mode: EditFileMode::Edit,
1452 },
1453 &stream_tx,
1454 cx,
1455 )
1456 });
1457
1458 let auth = stream_rx.expect_authorization().await;
1459 drop(auth); // deny by dropping
1460
1461 let result = authorize_task.await;
1462 assert!(result.is_err(), "should fail when denied");
1463 }
1464
1465 #[gpui::test]
1466 async fn test_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
1467 init_test(cx);
1468 cx.update(|cx| {
1469 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1470 settings.tool_permissions.tools.insert(
1471 "edit_file".into(),
1472 agent_settings::ToolRules {
1473 default: Some(settings::ToolPermissionMode::Deny),
1474 ..Default::default()
1475 },
1476 );
1477 agent_settings::AgentSettings::override_global(settings, cx);
1478 });
1479
1480 let fs = project::FakeFs::new(cx.executor());
1481 fs.insert_tree(
1482 path!("/root"),
1483 json!({
1484 "src": { "main.rs": "fn main() {}" }
1485 }),
1486 )
1487 .await;
1488 fs.insert_tree(
1489 path!("/outside"),
1490 json!({
1491 "config.txt": "old content"
1492 }),
1493 )
1494 .await;
1495 fs.create_symlink(
1496 path!("/root/link_to_external").as_ref(),
1497 PathBuf::from("/outside"),
1498 )
1499 .await
1500 .unwrap();
1501
1502 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1503 cx.executor().run_until_parked();
1504
1505 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1506 let context_server_registry =
1507 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1508 let model = Arc::new(FakeLanguageModel::default());
1509 let thread = cx.new(|cx| {
1510 Thread::new(
1511 project.clone(),
1512 cx.new(|_cx| ProjectContext::default()),
1513 context_server_registry,
1514 Templates::new(),
1515 Some(model),
1516 cx,
1517 )
1518 });
1519 let tool = Arc::new(EditFileTool::new(
1520 project.clone(),
1521 thread.downgrade(),
1522 language_registry,
1523 Templates::new(),
1524 ));
1525
1526 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1527 let result = cx
1528 .update(|cx| {
1529 tool.authorize(
1530 &EditFileToolInput {
1531 display_description: "edit through symlink".into(),
1532 path: PathBuf::from("link_to_external/config.txt"),
1533 mode: EditFileMode::Edit,
1534 },
1535 &stream_tx,
1536 cx,
1537 )
1538 })
1539 .await;
1540
1541 assert!(result.is_err(), "Tool should fail when policy denies");
1542 assert!(
1543 !matches!(
1544 stream_rx.try_next(),
1545 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
1546 ),
1547 "Deny policy should not emit symlink authorization prompt",
1548 );
1549 }
1550
1551 #[gpui::test]
1552 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1553 init_test(cx);
1554 let fs = project::FakeFs::new(cx.executor());
1555 fs.insert_tree("/project", json!({})).await;
1556 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1557 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1558 let context_server_registry =
1559 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1560 let model = Arc::new(FakeLanguageModel::default());
1561 let thread = cx.new(|cx| {
1562 Thread::new(
1563 project.clone(),
1564 cx.new(|_cx| ProjectContext::default()),
1565 context_server_registry,
1566 Templates::new(),
1567 Some(model.clone()),
1568 cx,
1569 )
1570 });
1571 let tool = Arc::new(EditFileTool::new(
1572 project.clone(),
1573 thread.downgrade(),
1574 language_registry,
1575 Templates::new(),
1576 ));
1577
1578 // Test global config paths - these should require confirmation if they exist and are outside the project
1579 let test_cases = vec![
1580 (
1581 "/etc/hosts",
1582 true,
1583 "System file should require confirmation",
1584 ),
1585 (
1586 "/usr/local/bin/script",
1587 true,
1588 "System bin file should require confirmation",
1589 ),
1590 (
1591 "project/normal_file.rs",
1592 false,
1593 "Normal project file should not require confirmation",
1594 ),
1595 ];
1596
1597 for (path, should_confirm, description) in test_cases {
1598 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1599 let auth = cx.update(|cx| {
1600 tool.authorize(
1601 &EditFileToolInput {
1602 display_description: "Edit file".into(),
1603 path: path.into(),
1604 mode: EditFileMode::Edit,
1605 },
1606 &stream_tx,
1607 cx,
1608 )
1609 });
1610
1611 if should_confirm {
1612 stream_rx.expect_authorization().await;
1613 } else {
1614 auth.await.unwrap();
1615 assert!(
1616 stream_rx.try_next().is_err(),
1617 "Failed for case: {} - path: {} - expected no confirmation but got one",
1618 description,
1619 path
1620 );
1621 }
1622 }
1623 }
1624
1625 #[gpui::test]
1626 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1627 init_test(cx);
1628 let fs = project::FakeFs::new(cx.executor());
1629
1630 // Create multiple worktree directories
1631 fs.insert_tree(
1632 "/workspace/frontend",
1633 json!({
1634 "src": {
1635 "main.js": "console.log('frontend');"
1636 }
1637 }),
1638 )
1639 .await;
1640 fs.insert_tree(
1641 "/workspace/backend",
1642 json!({
1643 "src": {
1644 "main.rs": "fn main() {}"
1645 }
1646 }),
1647 )
1648 .await;
1649 fs.insert_tree(
1650 "/workspace/shared",
1651 json!({
1652 ".zed": {
1653 "settings.json": "{}"
1654 }
1655 }),
1656 )
1657 .await;
1658
1659 // Create project with multiple worktrees
1660 let project = Project::test(
1661 fs.clone(),
1662 [
1663 path!("/workspace/frontend").as_ref(),
1664 path!("/workspace/backend").as_ref(),
1665 path!("/workspace/shared").as_ref(),
1666 ],
1667 cx,
1668 )
1669 .await;
1670 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1671 let context_server_registry =
1672 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1673 let model = Arc::new(FakeLanguageModel::default());
1674 let thread = cx.new(|cx| {
1675 Thread::new(
1676 project.clone(),
1677 cx.new(|_cx| ProjectContext::default()),
1678 context_server_registry.clone(),
1679 Templates::new(),
1680 Some(model.clone()),
1681 cx,
1682 )
1683 });
1684 let tool = Arc::new(EditFileTool::new(
1685 project.clone(),
1686 thread.downgrade(),
1687 language_registry,
1688 Templates::new(),
1689 ));
1690
1691 // Test files in different worktrees
1692 let test_cases = vec![
1693 ("frontend/src/main.js", false, "File in first worktree"),
1694 ("backend/src/main.rs", false, "File in second worktree"),
1695 (
1696 "shared/.zed/settings.json",
1697 true,
1698 ".zed file in third worktree",
1699 ),
1700 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1701 (
1702 "../outside/file.txt",
1703 true,
1704 "Relative path outside worktrees",
1705 ),
1706 ];
1707
1708 for (path, should_confirm, description) in test_cases {
1709 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1710 let auth = cx.update(|cx| {
1711 tool.authorize(
1712 &EditFileToolInput {
1713 display_description: "Edit file".into(),
1714 path: path.into(),
1715 mode: EditFileMode::Edit,
1716 },
1717 &stream_tx,
1718 cx,
1719 )
1720 });
1721
1722 if should_confirm {
1723 stream_rx.expect_authorization().await;
1724 } else {
1725 auth.await.unwrap();
1726 assert!(
1727 stream_rx.try_next().is_err(),
1728 "Failed for case: {} - path: {} - expected no confirmation but got one",
1729 description,
1730 path
1731 );
1732 }
1733 }
1734 }
1735
1736 #[gpui::test]
1737 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1738 init_test(cx);
1739 let fs = project::FakeFs::new(cx.executor());
1740 fs.insert_tree(
1741 "/project",
1742 json!({
1743 ".zed": {
1744 "settings.json": "{}"
1745 },
1746 "src": {
1747 ".zed": {
1748 "local.json": "{}"
1749 }
1750 }
1751 }),
1752 )
1753 .await;
1754 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1755 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1756 let context_server_registry =
1757 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1758 let model = Arc::new(FakeLanguageModel::default());
1759 let thread = cx.new(|cx| {
1760 Thread::new(
1761 project.clone(),
1762 cx.new(|_cx| ProjectContext::default()),
1763 context_server_registry.clone(),
1764 Templates::new(),
1765 Some(model.clone()),
1766 cx,
1767 )
1768 });
1769 let tool = Arc::new(EditFileTool::new(
1770 project.clone(),
1771 thread.downgrade(),
1772 language_registry,
1773 Templates::new(),
1774 ));
1775
1776 // Test edge cases
1777 let test_cases = vec![
1778 // Empty path - find_project_path returns Some for empty paths
1779 ("", false, "Empty path is treated as project root"),
1780 // Root directory
1781 ("/", true, "Root directory should be outside project"),
1782 // Parent directory references - find_project_path resolves these
1783 (
1784 "project/../other",
1785 true,
1786 "Path with .. that goes outside of root directory",
1787 ),
1788 (
1789 "project/./src/file.rs",
1790 false,
1791 "Path with . should work normally",
1792 ),
1793 // Windows-style paths (if on Windows)
1794 #[cfg(target_os = "windows")]
1795 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1796 #[cfg(target_os = "windows")]
1797 ("project\\src\\main.rs", false, "Windows-style project path"),
1798 ];
1799
1800 for (path, should_confirm, description) in test_cases {
1801 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1802 let auth = cx.update(|cx| {
1803 tool.authorize(
1804 &EditFileToolInput {
1805 display_description: "Edit file".into(),
1806 path: path.into(),
1807 mode: EditFileMode::Edit,
1808 },
1809 &stream_tx,
1810 cx,
1811 )
1812 });
1813
1814 cx.run_until_parked();
1815
1816 if should_confirm {
1817 stream_rx.expect_authorization().await;
1818 } else {
1819 assert!(
1820 stream_rx.try_next().is_err(),
1821 "Failed for case: {} - path: {} - expected no confirmation but got one",
1822 description,
1823 path
1824 );
1825 auth.await.unwrap();
1826 }
1827 }
1828 }
1829
1830 #[gpui::test]
1831 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1832 init_test(cx);
1833 let fs = project::FakeFs::new(cx.executor());
1834 fs.insert_tree(
1835 "/project",
1836 json!({
1837 "existing.txt": "content",
1838 ".zed": {
1839 "settings.json": "{}"
1840 }
1841 }),
1842 )
1843 .await;
1844 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1845 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1846 let context_server_registry =
1847 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1848 let model = Arc::new(FakeLanguageModel::default());
1849 let thread = cx.new(|cx| {
1850 Thread::new(
1851 project.clone(),
1852 cx.new(|_cx| ProjectContext::default()),
1853 context_server_registry.clone(),
1854 Templates::new(),
1855 Some(model.clone()),
1856 cx,
1857 )
1858 });
1859 let tool = Arc::new(EditFileTool::new(
1860 project.clone(),
1861 thread.downgrade(),
1862 language_registry,
1863 Templates::new(),
1864 ));
1865
1866 // Test different EditFileMode values
1867 let modes = vec![
1868 EditFileMode::Edit,
1869 EditFileMode::Create,
1870 EditFileMode::Overwrite,
1871 ];
1872
1873 for mode in modes {
1874 // Test .zed path with different modes
1875 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1876 let _auth = cx.update(|cx| {
1877 tool.authorize(
1878 &EditFileToolInput {
1879 display_description: "Edit settings".into(),
1880 path: "project/.zed/settings.json".into(),
1881 mode: mode.clone(),
1882 },
1883 &stream_tx,
1884 cx,
1885 )
1886 });
1887
1888 stream_rx.expect_authorization().await;
1889
1890 // Test outside path with different modes
1891 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1892 let _auth = cx.update(|cx| {
1893 tool.authorize(
1894 &EditFileToolInput {
1895 display_description: "Edit file".into(),
1896 path: "/outside/file.txt".into(),
1897 mode: mode.clone(),
1898 },
1899 &stream_tx,
1900 cx,
1901 )
1902 });
1903
1904 stream_rx.expect_authorization().await;
1905
1906 // Test normal path with different modes
1907 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1908 cx.update(|cx| {
1909 tool.authorize(
1910 &EditFileToolInput {
1911 display_description: "Edit file".into(),
1912 path: "project/normal.txt".into(),
1913 mode: mode.clone(),
1914 },
1915 &stream_tx,
1916 cx,
1917 )
1918 })
1919 .await
1920 .unwrap();
1921 assert!(stream_rx.try_next().is_err());
1922 }
1923 }
1924
1925 #[gpui::test]
1926 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1927 init_test(cx);
1928 let fs = project::FakeFs::new(cx.executor());
1929 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1930 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1931 let context_server_registry =
1932 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1933 let model = Arc::new(FakeLanguageModel::default());
1934 let thread = cx.new(|cx| {
1935 Thread::new(
1936 project.clone(),
1937 cx.new(|_cx| ProjectContext::default()),
1938 context_server_registry,
1939 Templates::new(),
1940 Some(model.clone()),
1941 cx,
1942 )
1943 });
1944 let tool = Arc::new(EditFileTool::new(
1945 project,
1946 thread.downgrade(),
1947 language_registry,
1948 Templates::new(),
1949 ));
1950
1951 cx.update(|cx| {
1952 // ...
1953 assert_eq!(
1954 tool.initial_title(
1955 Err(json!({
1956 "path": "src/main.rs",
1957 "display_description": "",
1958 "old_string": "old code",
1959 "new_string": "new code"
1960 })),
1961 cx
1962 ),
1963 "src/main.rs"
1964 );
1965 assert_eq!(
1966 tool.initial_title(
1967 Err(json!({
1968 "path": "",
1969 "display_description": "Fix error handling",
1970 "old_string": "old code",
1971 "new_string": "new code"
1972 })),
1973 cx
1974 ),
1975 "Fix error handling"
1976 );
1977 assert_eq!(
1978 tool.initial_title(
1979 Err(json!({
1980 "path": "src/main.rs",
1981 "display_description": "Fix error handling",
1982 "old_string": "old code",
1983 "new_string": "new code"
1984 })),
1985 cx
1986 ),
1987 "src/main.rs"
1988 );
1989 assert_eq!(
1990 tool.initial_title(
1991 Err(json!({
1992 "path": "",
1993 "display_description": "",
1994 "old_string": "old code",
1995 "new_string": "new code"
1996 })),
1997 cx
1998 ),
1999 DEFAULT_UI_TEXT
2000 );
2001 assert_eq!(
2002 tool.initial_title(Err(serde_json::Value::Null), cx),
2003 DEFAULT_UI_TEXT
2004 );
2005 });
2006 }
2007
2008 #[gpui::test]
2009 async fn test_diff_finalization(cx: &mut TestAppContext) {
2010 init_test(cx);
2011 let fs = project::FakeFs::new(cx.executor());
2012 fs.insert_tree("/", json!({"main.rs": ""})).await;
2013
2014 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
2015 let languages = project.read_with(cx, |project, _cx| project.languages().clone());
2016 let context_server_registry =
2017 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2018 let model = Arc::new(FakeLanguageModel::default());
2019 let thread = cx.new(|cx| {
2020 Thread::new(
2021 project.clone(),
2022 cx.new(|_cx| ProjectContext::default()),
2023 context_server_registry.clone(),
2024 Templates::new(),
2025 Some(model.clone()),
2026 cx,
2027 )
2028 });
2029
2030 // Ensure the diff is finalized after the edit completes.
2031 {
2032 let tool = Arc::new(EditFileTool::new(
2033 project.clone(),
2034 thread.downgrade(),
2035 languages.clone(),
2036 Templates::new(),
2037 ));
2038 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2039 let edit = cx.update(|cx| {
2040 tool.run(
2041 EditFileToolInput {
2042 display_description: "Edit file".into(),
2043 path: path!("/main.rs").into(),
2044 mode: EditFileMode::Edit,
2045 },
2046 stream_tx,
2047 cx,
2048 )
2049 });
2050 stream_rx.expect_update_fields().await;
2051 let diff = stream_rx.expect_diff().await;
2052 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2053 cx.run_until_parked();
2054 model.end_last_completion_stream();
2055 edit.await.unwrap();
2056 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2057 }
2058
2059 // Ensure the diff is finalized if an error occurs while editing.
2060 {
2061 model.forbid_requests();
2062 let tool = Arc::new(EditFileTool::new(
2063 project.clone(),
2064 thread.downgrade(),
2065 languages.clone(),
2066 Templates::new(),
2067 ));
2068 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2069 let edit = cx.update(|cx| {
2070 tool.run(
2071 EditFileToolInput {
2072 display_description: "Edit file".into(),
2073 path: path!("/main.rs").into(),
2074 mode: EditFileMode::Edit,
2075 },
2076 stream_tx,
2077 cx,
2078 )
2079 });
2080 stream_rx.expect_update_fields().await;
2081 let diff = stream_rx.expect_diff().await;
2082 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2083 edit.await.unwrap_err();
2084 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2085 model.allow_requests();
2086 }
2087
2088 // Ensure the diff is finalized if the tool call gets dropped.
2089 {
2090 let tool = Arc::new(EditFileTool::new(
2091 project.clone(),
2092 thread.downgrade(),
2093 languages.clone(),
2094 Templates::new(),
2095 ));
2096 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2097 let edit = cx.update(|cx| {
2098 tool.run(
2099 EditFileToolInput {
2100 display_description: "Edit file".into(),
2101 path: path!("/main.rs").into(),
2102 mode: EditFileMode::Edit,
2103 },
2104 stream_tx,
2105 cx,
2106 )
2107 });
2108 stream_rx.expect_update_fields().await;
2109 let diff = stream_rx.expect_diff().await;
2110 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2111 drop(edit);
2112 cx.run_until_parked();
2113 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2114 }
2115 }
2116
2117 #[gpui::test]
2118 async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
2119 init_test(cx);
2120
2121 let fs = project::FakeFs::new(cx.executor());
2122 fs.insert_tree(
2123 "/root",
2124 json!({
2125 "test.txt": "original content"
2126 }),
2127 )
2128 .await;
2129 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2130 let context_server_registry =
2131 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2132 let model = Arc::new(FakeLanguageModel::default());
2133 let thread = cx.new(|cx| {
2134 Thread::new(
2135 project.clone(),
2136 cx.new(|_cx| ProjectContext::default()),
2137 context_server_registry,
2138 Templates::new(),
2139 Some(model.clone()),
2140 cx,
2141 )
2142 });
2143 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2144
2145 // Initially, file_read_times should be empty
2146 let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
2147 assert!(is_empty, "file_read_times should start empty");
2148
2149 // Create read tool
2150 let read_tool = Arc::new(crate::ReadFileTool::new(
2151 thread.downgrade(),
2152 project.clone(),
2153 action_log,
2154 ));
2155
2156 // Read the file to record the read time
2157 cx.update(|cx| {
2158 read_tool.clone().run(
2159 crate::ReadFileToolInput {
2160 path: "root/test.txt".to_string(),
2161 start_line: None,
2162 end_line: None,
2163 },
2164 ToolCallEventStream::test().0,
2165 cx,
2166 )
2167 })
2168 .await
2169 .unwrap();
2170
2171 // Verify that file_read_times now contains an entry for the file
2172 let has_entry = thread.read_with(cx, |thread, _| {
2173 thread.file_read_times.len() == 1
2174 && thread
2175 .file_read_times
2176 .keys()
2177 .any(|path| path.ends_with("test.txt"))
2178 });
2179 assert!(
2180 has_entry,
2181 "file_read_times should contain an entry after reading the file"
2182 );
2183
2184 // Read the file again - should update the entry
2185 cx.update(|cx| {
2186 read_tool.clone().run(
2187 crate::ReadFileToolInput {
2188 path: "root/test.txt".to_string(),
2189 start_line: None,
2190 end_line: None,
2191 },
2192 ToolCallEventStream::test().0,
2193 cx,
2194 )
2195 })
2196 .await
2197 .unwrap();
2198
2199 // Should still have exactly one entry
2200 let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
2201 assert!(
2202 has_one_entry,
2203 "file_read_times should still have one entry after re-reading"
2204 );
2205 }
2206
2207 fn init_test(cx: &mut TestAppContext) {
2208 cx.update(|cx| {
2209 let settings_store = SettingsStore::test(cx);
2210 cx.set_global(settings_store);
2211 });
2212 }
2213
2214 #[gpui::test]
2215 async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
2216 init_test(cx);
2217
2218 let fs = project::FakeFs::new(cx.executor());
2219 fs.insert_tree(
2220 "/root",
2221 json!({
2222 "test.txt": "original content"
2223 }),
2224 )
2225 .await;
2226 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2227 let context_server_registry =
2228 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2229 let model = Arc::new(FakeLanguageModel::default());
2230 let thread = cx.new(|cx| {
2231 Thread::new(
2232 project.clone(),
2233 cx.new(|_cx| ProjectContext::default()),
2234 context_server_registry,
2235 Templates::new(),
2236 Some(model.clone()),
2237 cx,
2238 )
2239 });
2240 let languages = project.read_with(cx, |project, _| project.languages().clone());
2241 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2242
2243 let read_tool = Arc::new(crate::ReadFileTool::new(
2244 thread.downgrade(),
2245 project.clone(),
2246 action_log,
2247 ));
2248 let edit_tool = Arc::new(EditFileTool::new(
2249 project.clone(),
2250 thread.downgrade(),
2251 languages,
2252 Templates::new(),
2253 ));
2254
2255 // Read the file first
2256 cx.update(|cx| {
2257 read_tool.clone().run(
2258 crate::ReadFileToolInput {
2259 path: "root/test.txt".to_string(),
2260 start_line: None,
2261 end_line: None,
2262 },
2263 ToolCallEventStream::test().0,
2264 cx,
2265 )
2266 })
2267 .await
2268 .unwrap();
2269
2270 // First edit should work
2271 let edit_result = {
2272 let edit_task = cx.update(|cx| {
2273 edit_tool.clone().run(
2274 EditFileToolInput {
2275 display_description: "First edit".into(),
2276 path: "root/test.txt".into(),
2277 mode: EditFileMode::Edit,
2278 },
2279 ToolCallEventStream::test().0,
2280 cx,
2281 )
2282 });
2283
2284 cx.executor().run_until_parked();
2285 model.send_last_completion_stream_text_chunk(
2286 "<old_text>original content</old_text><new_text>modified content</new_text>"
2287 .to_string(),
2288 );
2289 model.end_last_completion_stream();
2290
2291 edit_task.await
2292 };
2293 assert!(
2294 edit_result.is_ok(),
2295 "First edit should succeed, got error: {:?}",
2296 edit_result.as_ref().err()
2297 );
2298
2299 // Second edit should also work because the edit updated the recorded read time
2300 let edit_result = {
2301 let edit_task = cx.update(|cx| {
2302 edit_tool.clone().run(
2303 EditFileToolInput {
2304 display_description: "Second edit".into(),
2305 path: "root/test.txt".into(),
2306 mode: EditFileMode::Edit,
2307 },
2308 ToolCallEventStream::test().0,
2309 cx,
2310 )
2311 });
2312
2313 cx.executor().run_until_parked();
2314 model.send_last_completion_stream_text_chunk(
2315 "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2316 );
2317 model.end_last_completion_stream();
2318
2319 edit_task.await
2320 };
2321 assert!(
2322 edit_result.is_ok(),
2323 "Second consecutive edit should succeed, got error: {:?}",
2324 edit_result.as_ref().err()
2325 );
2326 }
2327
2328 #[gpui::test]
2329 async fn test_external_modification_detected(cx: &mut TestAppContext) {
2330 init_test(cx);
2331
2332 let fs = project::FakeFs::new(cx.executor());
2333 fs.insert_tree(
2334 "/root",
2335 json!({
2336 "test.txt": "original content"
2337 }),
2338 )
2339 .await;
2340 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2341 let context_server_registry =
2342 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2343 let model = Arc::new(FakeLanguageModel::default());
2344 let thread = cx.new(|cx| {
2345 Thread::new(
2346 project.clone(),
2347 cx.new(|_cx| ProjectContext::default()),
2348 context_server_registry,
2349 Templates::new(),
2350 Some(model.clone()),
2351 cx,
2352 )
2353 });
2354 let languages = project.read_with(cx, |project, _| project.languages().clone());
2355 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2356
2357 let read_tool = Arc::new(crate::ReadFileTool::new(
2358 thread.downgrade(),
2359 project.clone(),
2360 action_log,
2361 ));
2362 let edit_tool = Arc::new(EditFileTool::new(
2363 project.clone(),
2364 thread.downgrade(),
2365 languages,
2366 Templates::new(),
2367 ));
2368
2369 // Read the file first
2370 cx.update(|cx| {
2371 read_tool.clone().run(
2372 crate::ReadFileToolInput {
2373 path: "root/test.txt".to_string(),
2374 start_line: None,
2375 end_line: None,
2376 },
2377 ToolCallEventStream::test().0,
2378 cx,
2379 )
2380 })
2381 .await
2382 .unwrap();
2383
2384 // Simulate external modification - advance time and save file
2385 cx.background_executor
2386 .advance_clock(std::time::Duration::from_secs(2));
2387 fs.save(
2388 path!("/root/test.txt").as_ref(),
2389 &"externally modified content".into(),
2390 language::LineEnding::Unix,
2391 )
2392 .await
2393 .unwrap();
2394
2395 // Reload the buffer to pick up the new mtime
2396 let project_path = project
2397 .read_with(cx, |project, cx| {
2398 project.find_project_path("root/test.txt", cx)
2399 })
2400 .expect("Should find project path");
2401 let buffer = project
2402 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2403 .await
2404 .unwrap();
2405 buffer
2406 .update(cx, |buffer, cx| buffer.reload(cx))
2407 .await
2408 .unwrap();
2409
2410 cx.executor().run_until_parked();
2411
2412 // Try to edit - should fail because file was modified externally
2413 let result = cx
2414 .update(|cx| {
2415 edit_tool.clone().run(
2416 EditFileToolInput {
2417 display_description: "Edit after external change".into(),
2418 path: "root/test.txt".into(),
2419 mode: EditFileMode::Edit,
2420 },
2421 ToolCallEventStream::test().0,
2422 cx,
2423 )
2424 })
2425 .await;
2426
2427 assert!(
2428 result.is_err(),
2429 "Edit should fail after external modification"
2430 );
2431 let error_msg = result.unwrap_err().to_string();
2432 assert!(
2433 error_msg.contains("has been modified since you last read it"),
2434 "Error should mention file modification, got: {}",
2435 error_msg
2436 );
2437 }
2438
2439 #[gpui::test]
2440 async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2441 init_test(cx);
2442
2443 let fs = project::FakeFs::new(cx.executor());
2444 fs.insert_tree(
2445 "/root",
2446 json!({
2447 "test.txt": "original content"
2448 }),
2449 )
2450 .await;
2451 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2452 let context_server_registry =
2453 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2454 let model = Arc::new(FakeLanguageModel::default());
2455 let thread = cx.new(|cx| {
2456 Thread::new(
2457 project.clone(),
2458 cx.new(|_cx| ProjectContext::default()),
2459 context_server_registry,
2460 Templates::new(),
2461 Some(model.clone()),
2462 cx,
2463 )
2464 });
2465 let languages = project.read_with(cx, |project, _| project.languages().clone());
2466 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2467
2468 let read_tool = Arc::new(crate::ReadFileTool::new(
2469 thread.downgrade(),
2470 project.clone(),
2471 action_log,
2472 ));
2473 let edit_tool = Arc::new(EditFileTool::new(
2474 project.clone(),
2475 thread.downgrade(),
2476 languages,
2477 Templates::new(),
2478 ));
2479
2480 // Read the file first
2481 cx.update(|cx| {
2482 read_tool.clone().run(
2483 crate::ReadFileToolInput {
2484 path: "root/test.txt".to_string(),
2485 start_line: None,
2486 end_line: None,
2487 },
2488 ToolCallEventStream::test().0,
2489 cx,
2490 )
2491 })
2492 .await
2493 .unwrap();
2494
2495 // Open the buffer and make it dirty by editing without saving
2496 let project_path = project
2497 .read_with(cx, |project, cx| {
2498 project.find_project_path("root/test.txt", cx)
2499 })
2500 .expect("Should find project path");
2501 let buffer = project
2502 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2503 .await
2504 .unwrap();
2505
2506 // Make an in-memory edit to the buffer (making it dirty)
2507 buffer.update(cx, |buffer, cx| {
2508 let end_point = buffer.max_point();
2509 buffer.edit([(end_point..end_point, " added text")], None, cx);
2510 });
2511
2512 // Verify buffer is dirty
2513 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2514 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2515
2516 // Try to edit - should fail because buffer has unsaved changes
2517 let result = cx
2518 .update(|cx| {
2519 edit_tool.clone().run(
2520 EditFileToolInput {
2521 display_description: "Edit with dirty buffer".into(),
2522 path: "root/test.txt".into(),
2523 mode: EditFileMode::Edit,
2524 },
2525 ToolCallEventStream::test().0,
2526 cx,
2527 )
2528 })
2529 .await;
2530
2531 assert!(result.is_err(), "Edit should fail when buffer is dirty");
2532 let error_msg = result.unwrap_err().to_string();
2533 assert!(
2534 error_msg.contains("This file has unsaved changes."),
2535 "Error should mention unsaved changes, got: {}",
2536 error_msg
2537 );
2538 assert!(
2539 error_msg.contains("keep or discard"),
2540 "Error should ask whether to keep or discard changes, got: {}",
2541 error_msg
2542 );
2543 // Since save_file and restore_file_from_disk tools aren't added to the thread,
2544 // the error message should ask the user to manually save or revert
2545 assert!(
2546 error_msg.contains("save or revert the file manually"),
2547 "Error should ask user to manually save or revert when tools aren't available, got: {}",
2548 error_msg
2549 );
2550 }
2551
2552 #[gpui::test]
2553 async fn test_sensitive_settings_kind_detects_nonexistent_subdirectory(
2554 cx: &mut TestAppContext,
2555 ) {
2556 let fs = project::FakeFs::new(cx.executor());
2557 let config_dir = paths::config_dir();
2558 fs.insert_tree(&*config_dir.to_string_lossy(), json!({}))
2559 .await;
2560 let path = config_dir.join("nonexistent_subdir_xyz").join("evil.json");
2561 assert!(
2562 matches!(
2563 sensitive_settings_kind(&path, fs.as_ref()).await,
2564 Some(SensitiveSettingsKind::Global)
2565 ),
2566 "Path in non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2567 path
2568 );
2569 }
2570
2571 #[gpui::test]
2572 async fn test_sensitive_settings_kind_detects_deeply_nested_nonexistent_subdirectory(
2573 cx: &mut TestAppContext,
2574 ) {
2575 let fs = project::FakeFs::new(cx.executor());
2576 let config_dir = paths::config_dir();
2577 fs.insert_tree(&*config_dir.to_string_lossy(), json!({}))
2578 .await;
2579 let path = config_dir.join("a").join("b").join("c").join("evil.json");
2580 assert!(
2581 matches!(
2582 sensitive_settings_kind(&path, fs.as_ref()).await,
2583 Some(SensitiveSettingsKind::Global)
2584 ),
2585 "Path in deeply nested non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2586 path
2587 );
2588 }
2589
2590 #[gpui::test]
2591 async fn test_sensitive_settings_kind_returns_none_for_non_config_path(
2592 cx: &mut TestAppContext,
2593 ) {
2594 let fs = project::FakeFs::new(cx.executor());
2595 let path = PathBuf::from("/tmp/not_a_config_dir/some_file.json");
2596 assert!(
2597 sensitive_settings_kind(&path, fs.as_ref()).await.is_none(),
2598 "Path outside config dir should not be detected as sensitive: {:?}",
2599 path
2600 );
2601 }
2602}