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