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