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