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