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