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