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