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