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