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