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