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