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