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