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