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