streaming_edit_file_tool.rs

   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}