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