1use super::edit_file_tool::EditFileTool;
2use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
3use super::save_file_tool::SaveFileTool;
4use crate::{
5 AgentTool, Thread, ToolCallEventStream, ToolInput,
6 edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher,
7};
8use acp_thread::Diff;
9use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10use anyhow::{Context as _, Result, anyhow};
11use collections::HashSet;
12use futures::FutureExt as _;
13use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
14use language::LanguageRegistry;
15use language::language_settings::{self, FormatOnSave};
16use language_model::LanguageModelToolResultContent;
17use project::lsp_store::{FormatTrigger, LspFormatTarget};
18use project::{Project, ProjectPath};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::ops::Range;
22use std::path::PathBuf;
23use std::sync::Arc;
24use text::BufferSnapshot;
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 StreamingEditFileToolInput {
41 /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI.
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
72 /// The mode of operation on the file. Possible values:
73 /// - 'create': Create a new file if it doesn't exist. Requires 'content' field.
74 /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field.
75 /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.
76 ///
77 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
78 pub mode: StreamingEditFileMode,
79
80 /// The complete content for the new file (required for 'create' and 'overwrite' modes).
81 /// This field should contain the entire file content.
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub content: Option<String>,
84
85 /// List of edit operations to apply sequentially (required for 'edit' mode).
86 /// Each edit finds `old_text` in the file and replaces it with `new_text`.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub edits: Option<Vec<EditOperation>>,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum StreamingEditFileMode {
94 /// Create a new file if it doesn't exist
95 Create,
96 /// Replace the entire contents of an existing file
97 Overwrite,
98 /// Make granular edits to an existing file
99 Edit,
100}
101
102/// A single edit operation that replaces old text with new text
103#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
104pub struct EditOperation {
105 /// The exact text to find in the file. This will be matched using fuzzy matching
106 /// to handle minor differences in whitespace or formatting.
107 pub old_text: String,
108 /// The text to replace it with
109 pub new_text: String,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
113struct StreamingEditFileToolPartialInput {
114 #[serde(default)]
115 path: String,
116 #[serde(default)]
117 display_description: String,
118}
119
120#[derive(Debug, Serialize, Deserialize)]
121#[serde(untagged)]
122pub enum StreamingEditFileToolOutput {
123 Success {
124 #[serde(alias = "original_path")]
125 input_path: PathBuf,
126 new_text: String,
127 old_text: Arc<String>,
128 #[serde(default)]
129 diff: String,
130 },
131 Error {
132 error: String,
133 },
134}
135
136impl std::fmt::Display for StreamingEditFileToolOutput {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 StreamingEditFileToolOutput::Success {
140 diff, input_path, ..
141 } => {
142 if diff.is_empty() {
143 write!(f, "No edits were made.")
144 } else {
145 write!(
146 f,
147 "Edited {}:\n\n```diff\n{diff}\n```",
148 input_path.display()
149 )
150 }
151 }
152 StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),
153 }
154 }
155}
156
157impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
158 fn from(output: StreamingEditFileToolOutput) -> Self {
159 output.to_string().into()
160 }
161}
162
163pub struct StreamingEditFileTool {
164 thread: WeakEntity<Thread>,
165 language_registry: Arc<LanguageRegistry>,
166 project: Entity<Project>,
167}
168
169impl StreamingEditFileTool {
170 pub fn new(
171 project: Entity<Project>,
172 thread: WeakEntity<Thread>,
173 language_registry: Arc<LanguageRegistry>,
174 ) -> Self {
175 Self {
176 project,
177 thread,
178 language_registry,
179 }
180 }
181
182 pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
183 Self {
184 project: self.project.clone(),
185 thread: new_thread,
186 language_registry: self.language_registry.clone(),
187 }
188 }
189
190 fn authorize(
191 &self,
192 input: &StreamingEditFileToolInput,
193 event_stream: &ToolCallEventStream,
194 cx: &mut App,
195 ) -> Task<Result<()>> {
196 super::tool_permissions::authorize_file_edit(
197 EditFileTool::NAME,
198 &input.path,
199 &input.display_description,
200 &self.thread,
201 event_stream,
202 cx,
203 )
204 }
205}
206
207impl AgentTool for StreamingEditFileTool {
208 type Input = StreamingEditFileToolInput;
209 type Output = StreamingEditFileToolOutput;
210
211 const NAME: &'static str = "streaming_edit_file";
212
213 fn kind() -> acp::ToolKind {
214 acp::ToolKind::Edit
215 }
216
217 fn initial_title(
218 &self,
219 input: Result<Self::Input, serde_json::Value>,
220 cx: &mut App,
221 ) -> SharedString {
222 match input {
223 Ok(input) => self
224 .project
225 .read(cx)
226 .find_project_path(&input.path, cx)
227 .and_then(|project_path| {
228 self.project
229 .read(cx)
230 .short_full_path_for_project_path(&project_path, cx)
231 })
232 .unwrap_or(input.path.to_string_lossy().into_owned())
233 .into(),
234 Err(raw_input) => {
235 if let Some(input) =
236 serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()
237 {
238 let path = input.path.trim();
239 if !path.is_empty() {
240 return self
241 .project
242 .read(cx)
243 .find_project_path(&input.path, cx)
244 .and_then(|project_path| {
245 self.project
246 .read(cx)
247 .short_full_path_for_project_path(&project_path, cx)
248 })
249 .unwrap_or(input.path)
250 .into();
251 }
252
253 let description = input.display_description.trim();
254 if !description.is_empty() {
255 return description.to_string().into();
256 }
257 }
258
259 DEFAULT_UI_TEXT.into()
260 }
261 }
262 }
263
264 fn run(
265 self: Arc<Self>,
266 input: ToolInput<Self::Input>,
267 event_stream: ToolCallEventStream,
268 cx: &mut App,
269 ) -> Task<Result<Self::Output, Self::Output>> {
270 cx.spawn(async move |cx: &mut AsyncApp| {
271 let input = input.recv().await.map_err(|e| {
272 StreamingEditFileToolOutput::Error {
273 error: format!("Failed to receive tool input: {e}"),
274 }
275 })?;
276
277 let project = self
278 .thread
279 .read_with(cx, |thread, _cx| thread.project().clone())
280 .map_err(|_| StreamingEditFileToolOutput::Error {
281 error: "thread was dropped".to_string(),
282 })?;
283
284 let (project_path, abs_path, authorize) = cx.update(|cx| {
285 let project_path =
286 resolve_path(&input, project.clone(), cx).map_err(|err| {
287 StreamingEditFileToolOutput::Error {
288 error: err.to_string(),
289 }
290 })?;
291 let abs_path = project.read(cx).absolute_path(&project_path, cx);
292 if let Some(abs_path) = abs_path.clone() {
293 event_stream.update_fields(
294 ToolCallUpdateFields::new()
295 .locations(vec![acp::ToolCallLocation::new(abs_path)]),
296 );
297 }
298 let authorize = self.authorize(&input, &event_stream, cx);
299 Ok::<_, StreamingEditFileToolOutput>((project_path, abs_path, authorize))
300 })?;
301 let result: anyhow::Result<StreamingEditFileToolOutput> = async {
302 authorize.await?;
303
304 let buffer = project
305 .update(cx, |project, cx| {
306 project.open_buffer(project_path.clone(), cx)
307 })
308 .await?;
309
310 if let Some(abs_path) = abs_path.as_ref() {
311 let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) =
312 self.thread.update(cx, |thread, cx| {
313 let last_read = thread.file_read_times.get(abs_path).copied();
314 let current = buffer
315 .read(cx)
316 .file()
317 .and_then(|file| file.disk_state().mtime());
318 let dirty = buffer.read(cx).is_dirty();
319 let has_save = thread.has_tool(SaveFileTool::NAME);
320 let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
321 (last_read, current, dirty, has_save, has_restore)
322 })?;
323
324 if is_dirty {
325 let message = match (has_save_tool, has_restore_tool) {
326 (true, true) => {
327 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
328 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
329 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."
330 }
331 (true, false) => {
332 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
333 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
334 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
335 }
336 (false, true) => {
337 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
338 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
339 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."
340 }
341 (false, false) => {
342 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
343 then ask them to save or revert the file manually and inform you when it's ok to proceed."
344 }
345 };
346 anyhow::bail!("{}", message);
347 }
348
349 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
350 if current != last_read {
351 anyhow::bail!(
352 "The file {} has been modified since you last read it. \
353 Please read the file again to get the current state before editing it.",
354 input.path.display()
355 );
356 }
357 }
358 }
359
360 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
361 event_stream.update_diff(diff.clone());
362 let _finalize_diff = util::defer({
363 let diff = diff.downgrade();
364 let mut cx = cx.clone();
365 move || {
366 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
367 }
368 });
369
370 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
371 let old_text = cx
372 .background_spawn({
373 let old_snapshot = old_snapshot.clone();
374 async move { Arc::new(old_snapshot.text()) }
375 })
376 .await;
377
378 let action_log = self.thread.read_with(cx, |thread, _cx| thread.action_log().clone())?;
379
380 // Edit the buffer and report edits to the action log as part of the
381 // same effect cycle, otherwise the edit will be reported as if the
382 // user made it (due to the buffer subscription in action_log).
383 match input.mode {
384 StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
385 action_log.update(cx, |log, cx| {
386 log.buffer_created(buffer.clone(), cx);
387 });
388 let content = input.content.ok_or_else(|| {
389 anyhow!("'content' field is required for create and overwrite modes")
390 })?;
391 cx.update(|cx| {
392 buffer.update(cx, |buffer, cx| {
393 buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
394 });
395 action_log.update(cx, |log, cx| {
396 log.buffer_edited(buffer.clone(), cx);
397 });
398 });
399 }
400 StreamingEditFileMode::Edit => {
401 action_log.update(cx, |log, cx| {
402 log.buffer_read(buffer.clone(), cx);
403 });
404 let edits = input.edits.ok_or_else(|| {
405 anyhow!("'edits' field is required for edit mode")
406 })?;
407 // apply_edits now handles buffer_edited internally in the same effect cycle
408 apply_edits(&buffer, &action_log, &edits, &diff, &event_stream, &abs_path, cx)?;
409 }
410 }
411
412 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
413 let settings = language_settings::language_settings(
414 buffer.language().map(|l| l.name()),
415 buffer.file(),
416 cx,
417 );
418 settings.format_on_save != FormatOnSave::Off
419 });
420
421 if format_on_save_enabled {
422 action_log.update(cx, |log, cx| {
423 log.buffer_edited(buffer.clone(), cx);
424 });
425
426 let format_task = project.update(cx, |project, cx| {
427 project.format(
428 HashSet::from_iter([buffer.clone()]),
429 LspFormatTarget::Buffers,
430 false,
431 FormatTrigger::Save,
432 cx,
433 )
434 });
435 futures::select! {
436 result = format_task.fuse() => { result.log_err(); },
437 _ = event_stream.cancelled_by_user().fuse() => {
438 anyhow::bail!("Edit cancelled by user");
439 }
440 };
441 }
442
443 let save_task = project
444 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
445 futures::select! {
446 result = save_task.fuse() => { result?; },
447 _ = event_stream.cancelled_by_user().fuse() => {
448 anyhow::bail!("Edit cancelled by user");
449 }
450 };
451
452 action_log.update(cx, |log, cx| {
453 log.buffer_edited(buffer.clone(), cx);
454 });
455
456 if let Some(abs_path) = abs_path.as_ref() {
457 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
458 buffer.file().and_then(|file| file.disk_state().mtime())
459 }) {
460 self.thread.update(cx, |thread, _| {
461 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
462 })?;
463 }
464 }
465
466 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
467 let (new_text, unified_diff) = cx
468 .background_spawn({
469 let new_snapshot = new_snapshot.clone();
470 let old_text = old_text.clone();
471 async move {
472 let new_text = new_snapshot.text();
473 let diff = language::unified_diff(&old_text, &new_text);
474 (new_text, diff)
475 }
476 })
477 .await;
478
479 let output = StreamingEditFileToolOutput::Success {
480 input_path: input.path,
481 new_text,
482 old_text,
483 diff: unified_diff,
484 };
485
486 Ok(output)
487 }.await;
488 result
489 .map_err(|e| StreamingEditFileToolOutput::Error { error: e.to_string() })
490 })
491 }
492
493 fn replay(
494 &self,
495 _input: Self::Input,
496 output: Self::Output,
497 event_stream: ToolCallEventStream,
498 cx: &mut App,
499 ) -> Result<()> {
500 match output {
501 StreamingEditFileToolOutput::Success {
502 input_path,
503 old_text,
504 new_text,
505 ..
506 } => {
507 event_stream.update_diff(cx.new(|cx| {
508 Diff::finalized(
509 input_path.to_string_lossy().into_owned(),
510 Some(old_text.to_string()),
511 new_text,
512 self.language_registry.clone(),
513 cx,
514 )
515 }));
516 Ok(())
517 }
518 StreamingEditFileToolOutput::Error { .. } => Ok(()),
519 }
520 }
521}
522
523fn apply_edits(
524 buffer: &Entity<language::Buffer>,
525 action_log: &Entity<action_log::ActionLog>,
526 edits: &[EditOperation],
527 diff: &Entity<Diff>,
528 event_stream: &ToolCallEventStream,
529 abs_path: &Option<PathBuf>,
530 cx: &mut AsyncApp,
531) -> Result<()> {
532 let mut failed_edits = Vec::new();
533 let mut ambiguous_edits = Vec::new();
534 let mut resolved_edits: Vec<(Range<usize>, String)> = Vec::new();
535
536 // First pass: resolve all edits without applying them
537 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
538 for (index, edit) in edits.iter().enumerate() {
539 let result = resolve_edit(&snapshot, edit);
540
541 match result {
542 Ok(Some((range, new_text))) => {
543 // Reveal the range in the diff view
544 let (start_anchor, end_anchor) = buffer.read_with(cx, |buffer, _cx| {
545 (
546 buffer.anchor_before(range.start),
547 buffer.anchor_after(range.end),
548 )
549 });
550 diff.update(cx, |card, cx| {
551 card.reveal_range(start_anchor..end_anchor, cx)
552 });
553 resolved_edits.push((range, new_text));
554 }
555 Ok(None) => {
556 failed_edits.push(index);
557 }
558 Err(ranges) => {
559 ambiguous_edits.push((index, ranges));
560 }
561 }
562 }
563
564 // Check for errors before applying any edits
565 if !failed_edits.is_empty() {
566 let indices = failed_edits
567 .iter()
568 .map(|i| i.to_string())
569 .collect::<Vec<_>>()
570 .join(", ");
571 anyhow::bail!(
572 "Could not find matching text for edit(s) at index(es): {}. \
573 The old_text did not match any content in the file. \
574 Please read the file again to get the current content.",
575 indices
576 );
577 }
578
579 if !ambiguous_edits.is_empty() {
580 let details: Vec<String> = ambiguous_edits
581 .iter()
582 .map(|(index, ranges)| {
583 let lines = ranges
584 .iter()
585 .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
586 .collect::<Vec<_>>()
587 .join(", ");
588 format!("edit {}: matches at lines {}", index, lines)
589 })
590 .collect();
591 anyhow::bail!(
592 "Some edits matched multiple locations in the file:\n{}. \
593 Please provide more context in old_text to uniquely identify the location.",
594 details.join("\n")
595 );
596 }
597
598 // Sort edits by position so buffer.edit() can handle offset translation
599 let mut edits_sorted = resolved_edits;
600 edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start));
601
602 // Emit location for the earliest edit in the file
603 if let Some((first_range, _)) = edits_sorted.first() {
604 if let Some(abs_path) = abs_path.clone() {
605 let line = snapshot.offset_to_point(first_range.start).row;
606 event_stream.update_fields(
607 ToolCallUpdateFields::new()
608 .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
609 );
610 }
611 }
612
613 // Validate no overlaps (sorted ascending by start)
614 for window in edits_sorted.windows(2) {
615 if let [(earlier_range, _), (later_range, _)] = window
616 && (earlier_range.end > later_range.start || earlier_range.start == later_range.start)
617 {
618 let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1;
619 let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1;
620 let later_start_line = snapshot.offset_to_point(later_range.start).row + 1;
621 let later_end_line = snapshot.offset_to_point(later_range.end).row + 1;
622 anyhow::bail!(
623 "Conflicting edit ranges detected: lines {}-{} conflicts with lines {}-{}. \
624 Conflicting edit ranges are not allowed, as they would overwrite each other.",
625 earlier_start_line,
626 earlier_end_line,
627 later_start_line,
628 later_end_line,
629 );
630 }
631 }
632
633 // Apply all edits in a single batch and report to action_log in the same
634 // effect cycle. This prevents the buffer subscription from treating these
635 // as user edits.
636 if !edits_sorted.is_empty() {
637 cx.update(|cx| {
638 buffer.update(cx, |buffer, cx| {
639 buffer.edit(
640 edits_sorted
641 .iter()
642 .map(|(range, new_text)| (range.clone(), new_text.as_str())),
643 None,
644 cx,
645 );
646 });
647 action_log.update(cx, |log, cx| {
648 log.buffer_edited(buffer.clone(), cx);
649 });
650 });
651 }
652
653 Ok(())
654}
655
656/// Resolves an edit operation by finding the matching text in the buffer.
657/// Returns Ok(Some((range, new_text))) if a unique match is found,
658/// Ok(None) if no match is found, or Err(ranges) if multiple matches are found.
659fn resolve_edit(
660 snapshot: &BufferSnapshot,
661 edit: &EditOperation,
662) -> std::result::Result<Option<(Range<usize>, String)>, Vec<Range<usize>>> {
663 let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
664 matcher.push(&edit.old_text, None);
665 let matches = matcher.finish();
666
667 if matches.is_empty() {
668 return Ok(None);
669 }
670
671 if matches.len() > 1 {
672 return Err(matches);
673 }
674
675 let match_range = matches.into_iter().next().expect("checked len above");
676 Ok(Some((match_range, edit.new_text.clone())))
677}
678
679fn resolve_path(
680 input: &StreamingEditFileToolInput,
681 project: Entity<Project>,
682 cx: &mut App,
683) -> Result<ProjectPath> {
684 let project = project.read(cx);
685
686 match input.mode {
687 StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => {
688 let path = project
689 .find_project_path(&input.path, cx)
690 .context("Can't edit file: path not found")?;
691
692 let entry = project
693 .entry_for_path(&path, cx)
694 .context("Can't edit file: path not found")?;
695
696 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
697 Ok(path)
698 }
699
700 StreamingEditFileMode::Create => {
701 if let Some(path) = project.find_project_path(&input.path, cx) {
702 anyhow::ensure!(
703 project.entry_for_path(&path, cx).is_none(),
704 "Can't create file: file already exists"
705 );
706 }
707
708 let parent_path = input
709 .path
710 .parent()
711 .context("Can't create file: incorrect path")?;
712
713 let parent_project_path = project.find_project_path(&parent_path, cx);
714
715 let parent_entry = parent_project_path
716 .as_ref()
717 .and_then(|path| project.entry_for_path(path, cx))
718 .context("Can't create file: parent directory doesn't exist")?;
719
720 anyhow::ensure!(
721 parent_entry.is_dir(),
722 "Can't create file: parent is not a directory"
723 );
724
725 let file_name = input
726 .path
727 .file_name()
728 .and_then(|file_name| file_name.to_str())
729 .and_then(|file_name| RelPath::unix(file_name).ok())
730 .context("Can't create file: invalid filename")?;
731
732 let new_file_path = parent_project_path.map(|parent| ProjectPath {
733 path: parent.path.join(file_name),
734 ..parent
735 });
736
737 new_file_path.context("Can't create file")
738 }
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use crate::{ContextServerRegistry, Templates};
746 use gpui::{TestAppContext, UpdateGlobal};
747 use language_model::fake_provider::FakeLanguageModel;
748 use prompt_store::ProjectContext;
749 use serde_json::json;
750 use settings::SettingsStore;
751 use util::path;
752
753 #[gpui::test]
754 async fn test_streaming_edit_create_file(cx: &mut TestAppContext) {
755 init_test(cx);
756
757 let fs = project::FakeFs::new(cx.executor());
758 fs.insert_tree("/root", json!({"dir": {}})).await;
759 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
760 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
761 let context_server_registry =
762 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
763 let model = Arc::new(FakeLanguageModel::default());
764 let thread = cx.new(|cx| {
765 crate::Thread::new(
766 project.clone(),
767 cx.new(|_cx| ProjectContext::default()),
768 context_server_registry,
769 Templates::new(),
770 Some(model),
771 cx,
772 )
773 });
774
775 let result = cx
776 .update(|cx| {
777 let input = StreamingEditFileToolInput {
778 display_description: "Create new file".into(),
779 path: "root/dir/new_file.txt".into(),
780 mode: StreamingEditFileMode::Create,
781 content: Some("Hello, World!".into()),
782 edits: None,
783 };
784 Arc::new(StreamingEditFileTool::new(
785 project.clone(),
786 thread.downgrade(),
787 language_registry,
788 ))
789 .run(
790 ToolInput::resolved(input),
791 ToolCallEventStream::test().0,
792 cx,
793 )
794 })
795 .await;
796
797 let StreamingEditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else {
798 panic!("expected success");
799 };
800 assert_eq!(new_text, "Hello, World!");
801 assert!(!diff.is_empty());
802 }
803
804 #[gpui::test]
805 async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) {
806 init_test(cx);
807
808 let fs = project::FakeFs::new(cx.executor());
809 fs.insert_tree("/root", json!({"file.txt": "old content"}))
810 .await;
811 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
812 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
813 let context_server_registry =
814 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
815 let model = Arc::new(FakeLanguageModel::default());
816 let thread = cx.new(|cx| {
817 crate::Thread::new(
818 project.clone(),
819 cx.new(|_cx| ProjectContext::default()),
820 context_server_registry,
821 Templates::new(),
822 Some(model),
823 cx,
824 )
825 });
826
827 let result = cx
828 .update(|cx| {
829 let input = StreamingEditFileToolInput {
830 display_description: "Overwrite file".into(),
831 path: "root/file.txt".into(),
832 mode: StreamingEditFileMode::Overwrite,
833 content: Some("new content".into()),
834 edits: None,
835 };
836 Arc::new(StreamingEditFileTool::new(
837 project.clone(),
838 thread.downgrade(),
839 language_registry,
840 ))
841 .run(
842 ToolInput::resolved(input),
843 ToolCallEventStream::test().0,
844 cx,
845 )
846 })
847 .await;
848
849 let StreamingEditFileToolOutput::Success {
850 new_text, old_text, ..
851 } = result.unwrap()
852 else {
853 panic!("expected success");
854 };
855 assert_eq!(new_text, "new content");
856 assert_eq!(*old_text, "old content");
857 }
858
859 #[gpui::test]
860 async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) {
861 init_test(cx);
862
863 let fs = project::FakeFs::new(cx.executor());
864 fs.insert_tree(
865 "/root",
866 json!({
867 "file.txt": "line 1\nline 2\nline 3\n"
868 }),
869 )
870 .await;
871 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
872 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
873 let context_server_registry =
874 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
875 let model = Arc::new(FakeLanguageModel::default());
876 let thread = cx.new(|cx| {
877 crate::Thread::new(
878 project.clone(),
879 cx.new(|_cx| ProjectContext::default()),
880 context_server_registry,
881 Templates::new(),
882 Some(model),
883 cx,
884 )
885 });
886
887 let result = cx
888 .update(|cx| {
889 let input = StreamingEditFileToolInput {
890 display_description: "Edit lines".into(),
891 path: "root/file.txt".into(),
892 mode: StreamingEditFileMode::Edit,
893 content: None,
894 edits: Some(vec![EditOperation {
895 old_text: "line 2".into(),
896 new_text: "modified line 2".into(),
897 }]),
898 };
899 Arc::new(StreamingEditFileTool::new(
900 project.clone(),
901 thread.downgrade(),
902 language_registry,
903 ))
904 .run(
905 ToolInput::resolved(input),
906 ToolCallEventStream::test().0,
907 cx,
908 )
909 })
910 .await;
911
912 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
913 panic!("expected success");
914 };
915 assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
916 }
917
918 #[gpui::test]
919 async fn test_streaming_edit_multiple_nonoverlapping_edits(cx: &mut TestAppContext) {
920 init_test(cx);
921
922 let fs = project::FakeFs::new(cx.executor());
923 fs.insert_tree(
924 "/root",
925 json!({
926 "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
927 }),
928 )
929 .await;
930 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
931 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
932 let context_server_registry =
933 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
934 let model = Arc::new(FakeLanguageModel::default());
935 let thread = cx.new(|cx| {
936 crate::Thread::new(
937 project.clone(),
938 cx.new(|_cx| ProjectContext::default()),
939 context_server_registry,
940 Templates::new(),
941 Some(model),
942 cx,
943 )
944 });
945
946 let result = cx
947 .update(|cx| {
948 let input = StreamingEditFileToolInput {
949 display_description: "Edit multiple lines".into(),
950 path: "root/file.txt".into(),
951 mode: StreamingEditFileMode::Edit,
952 content: None,
953 edits: Some(vec![
954 EditOperation {
955 old_text: "line 5".into(),
956 new_text: "modified line 5".into(),
957 },
958 EditOperation {
959 old_text: "line 1".into(),
960 new_text: "modified line 1".into(),
961 },
962 ]),
963 };
964 Arc::new(StreamingEditFileTool::new(
965 project.clone(),
966 thread.downgrade(),
967 language_registry,
968 ))
969 .run(
970 ToolInput::resolved(input),
971 ToolCallEventStream::test().0,
972 cx,
973 )
974 })
975 .await;
976
977 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
978 panic!("expected success");
979 };
980 assert_eq!(
981 new_text,
982 "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
983 );
984 }
985
986 #[gpui::test]
987 async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) {
988 init_test(cx);
989
990 let fs = project::FakeFs::new(cx.executor());
991 fs.insert_tree(
992 "/root",
993 json!({
994 "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
995 }),
996 )
997 .await;
998 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
999 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1000 let context_server_registry =
1001 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1002 let model = Arc::new(FakeLanguageModel::default());
1003 let thread = cx.new(|cx| {
1004 crate::Thread::new(
1005 project.clone(),
1006 cx.new(|_cx| ProjectContext::default()),
1007 context_server_registry,
1008 Templates::new(),
1009 Some(model),
1010 cx,
1011 )
1012 });
1013
1014 let result = cx
1015 .update(|cx| {
1016 let input = StreamingEditFileToolInput {
1017 display_description: "Edit adjacent lines".into(),
1018 path: "root/file.txt".into(),
1019 mode: StreamingEditFileMode::Edit,
1020 content: None,
1021 edits: Some(vec![
1022 EditOperation {
1023 old_text: "line 2".into(),
1024 new_text: "modified line 2".into(),
1025 },
1026 EditOperation {
1027 old_text: "line 3".into(),
1028 new_text: "modified line 3".into(),
1029 },
1030 ]),
1031 };
1032 Arc::new(StreamingEditFileTool::new(
1033 project.clone(),
1034 thread.downgrade(),
1035 language_registry,
1036 ))
1037 .run(
1038 ToolInput::resolved(input),
1039 ToolCallEventStream::test().0,
1040 cx,
1041 )
1042 })
1043 .await;
1044
1045 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1046 panic!("expected success");
1047 };
1048 assert_eq!(
1049 new_text,
1050 "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n"
1051 );
1052 }
1053
1054 #[gpui::test]
1055 async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) {
1056 init_test(cx);
1057
1058 let fs = project::FakeFs::new(cx.executor());
1059 fs.insert_tree(
1060 "/root",
1061 json!({
1062 "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1063 }),
1064 )
1065 .await;
1066 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1067 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1068 let context_server_registry =
1069 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1070 let model = Arc::new(FakeLanguageModel::default());
1071 let thread = cx.new(|cx| {
1072 crate::Thread::new(
1073 project.clone(),
1074 cx.new(|_cx| ProjectContext::default()),
1075 context_server_registry,
1076 Templates::new(),
1077 Some(model),
1078 cx,
1079 )
1080 });
1081
1082 let result = cx
1083 .update(|cx| {
1084 let input = StreamingEditFileToolInput {
1085 display_description: "Edit multiple lines in ascending order".into(),
1086 path: "root/file.txt".into(),
1087 mode: StreamingEditFileMode::Edit,
1088 content: None,
1089 edits: Some(vec![
1090 EditOperation {
1091 old_text: "line 1".into(),
1092 new_text: "modified line 1".into(),
1093 },
1094 EditOperation {
1095 old_text: "line 5".into(),
1096 new_text: "modified line 5".into(),
1097 },
1098 ]),
1099 };
1100 Arc::new(StreamingEditFileTool::new(
1101 project.clone(),
1102 thread.downgrade(),
1103 language_registry,
1104 ))
1105 .run(
1106 ToolInput::resolved(input),
1107 ToolCallEventStream::test().0,
1108 cx,
1109 )
1110 })
1111 .await;
1112
1113 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1114 panic!("expected success");
1115 };
1116 assert_eq!(
1117 new_text,
1118 "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1119 );
1120 }
1121
1122 #[gpui::test]
1123 async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) {
1124 init_test(cx);
1125
1126 let fs = project::FakeFs::new(cx.executor());
1127 fs.insert_tree("/root", json!({})).await;
1128 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1129 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1130 let context_server_registry =
1131 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1132 let model = Arc::new(FakeLanguageModel::default());
1133 let thread = cx.new(|cx| {
1134 crate::Thread::new(
1135 project.clone(),
1136 cx.new(|_cx| ProjectContext::default()),
1137 context_server_registry,
1138 Templates::new(),
1139 Some(model),
1140 cx,
1141 )
1142 });
1143
1144 let result = cx
1145 .update(|cx| {
1146 let input = StreamingEditFileToolInput {
1147 display_description: "Some edit".into(),
1148 path: "root/nonexistent_file.txt".into(),
1149 mode: StreamingEditFileMode::Edit,
1150 content: None,
1151 edits: Some(vec![EditOperation {
1152 old_text: "foo".into(),
1153 new_text: "bar".into(),
1154 }]),
1155 };
1156 Arc::new(StreamingEditFileTool::new(
1157 project,
1158 thread.downgrade(),
1159 language_registry,
1160 ))
1161 .run(
1162 ToolInput::resolved(input),
1163 ToolCallEventStream::test().0,
1164 cx,
1165 )
1166 })
1167 .await;
1168
1169 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1170 panic!("expected error");
1171 };
1172 assert_eq!(error, "Can't edit file: path not found");
1173 }
1174
1175 #[gpui::test]
1176 async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) {
1177 init_test(cx);
1178
1179 let fs = project::FakeFs::new(cx.executor());
1180 fs.insert_tree("/root", json!({"file.txt": "hello world"}))
1181 .await;
1182 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1183 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1184 let context_server_registry =
1185 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1186 let model = Arc::new(FakeLanguageModel::default());
1187 let thread = cx.new(|cx| {
1188 crate::Thread::new(
1189 project.clone(),
1190 cx.new(|_cx| ProjectContext::default()),
1191 context_server_registry,
1192 Templates::new(),
1193 Some(model),
1194 cx,
1195 )
1196 });
1197
1198 let result = cx
1199 .update(|cx| {
1200 let input = StreamingEditFileToolInput {
1201 display_description: "Edit file".into(),
1202 path: "root/file.txt".into(),
1203 mode: StreamingEditFileMode::Edit,
1204 content: None,
1205 edits: Some(vec![EditOperation {
1206 old_text: "nonexistent text that is not in the file".into(),
1207 new_text: "replacement".into(),
1208 }]),
1209 };
1210 Arc::new(StreamingEditFileTool::new(
1211 project,
1212 thread.downgrade(),
1213 language_registry,
1214 ))
1215 .run(
1216 ToolInput::resolved(input),
1217 ToolCallEventStream::test().0,
1218 cx,
1219 )
1220 })
1221 .await;
1222
1223 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1224 panic!("expected error");
1225 };
1226 assert!(
1227 error.contains("Could not find matching text"),
1228 "Expected error containing 'Could not find matching text' but got: {error}"
1229 );
1230 }
1231
1232 #[gpui::test]
1233 async fn test_streaming_edit_overlapping_edits_out_of_order(cx: &mut TestAppContext) {
1234 init_test(cx);
1235
1236 let fs = project::FakeFs::new(cx.executor());
1237 // Multi-line file so the line-based fuzzy matcher can resolve each edit.
1238 fs.insert_tree(
1239 "/root",
1240 json!({
1241 "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1242 }),
1243 )
1244 .await;
1245 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1246 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1247 let context_server_registry =
1248 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1249 let model = Arc::new(FakeLanguageModel::default());
1250 let thread = cx.new(|cx| {
1251 crate::Thread::new(
1252 project.clone(),
1253 cx.new(|_cx| ProjectContext::default()),
1254 context_server_registry,
1255 Templates::new(),
1256 Some(model),
1257 cx,
1258 )
1259 });
1260
1261 // Edit A spans lines 3-4, edit B spans lines 2-3. They overlap on
1262 // "line 3" and are given in descending file order so the ascending
1263 // sort must reorder them before the pairwise overlap check can
1264 // detect them correctly.
1265 let result = cx
1266 .update(|cx| {
1267 let input = StreamingEditFileToolInput {
1268 display_description: "Overlapping edits".into(),
1269 path: "root/file.txt".into(),
1270 mode: StreamingEditFileMode::Edit,
1271 content: None,
1272 edits: Some(vec![
1273 EditOperation {
1274 old_text: "line 3\nline 4".into(),
1275 new_text: "SECOND".into(),
1276 },
1277 EditOperation {
1278 old_text: "line 2\nline 3".into(),
1279 new_text: "FIRST".into(),
1280 },
1281 ]),
1282 };
1283 Arc::new(StreamingEditFileTool::new(
1284 project,
1285 thread.downgrade(),
1286 language_registry,
1287 ))
1288 .run(
1289 ToolInput::resolved(input),
1290 ToolCallEventStream::test().0,
1291 cx,
1292 )
1293 })
1294 .await;
1295
1296 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1297 panic!("expected error");
1298 };
1299 assert!(
1300 error.contains("Conflicting edit ranges detected"),
1301 "Expected 'Conflicting edit ranges detected' but got: {error}"
1302 );
1303 }
1304
1305 fn init_test(cx: &mut TestAppContext) {
1306 cx.update(|cx| {
1307 let settings_store = SettingsStore::test(cx);
1308 cx.set_global(settings_store);
1309 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
1310 store.update_user_settings(cx, |settings| {
1311 settings
1312 .project
1313 .all_languages
1314 .defaults
1315 .ensure_final_newline_on_save = Some(false);
1316 });
1317 });
1318 });
1319 }
1320}