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::language_settings::{self, FormatOnSave};
  15use language::{Buffer, LanguageRegistry};
  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, ToOffset as _};
  25use ui::SharedString;
  26use util::rel_path::RelPath;
  27use util::{Deferred, ResultExt, debug_panic};
  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: String,
  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(Default, Debug, Deserialize)]
 113struct StreamingEditFileToolPartialInput {
 114    #[serde(default)]
 115    display_description: Option<String>,
 116    #[serde(default)]
 117    path: Option<String>,
 118    #[serde(default)]
 119    mode: Option<StreamingEditFileMode>,
 120    #[serde(default)]
 121    #[allow(dead_code)]
 122    content: Option<String>,
 123    #[serde(default)]
 124    edits: Option<Vec<PartialEditOperation>>,
 125}
 126
 127#[derive(Default, Debug, Deserialize)]
 128struct PartialEditOperation {
 129    #[serde(default)]
 130    old_text: Option<String>,
 131    #[serde(default)]
 132    new_text: Option<String>,
 133}
 134
 135enum StreamingEditState {
 136    Idle,
 137    BufferResolved {
 138        abs_path: PathBuf,
 139        buffer: Entity<Buffer>,
 140        old_text: Arc<String>,
 141        diff: Entity<Diff>,
 142        edit_state: IncrementalEditState,
 143        _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
 144    },
 145}
 146
 147#[derive(Default)]
 148struct IncrementalEditState {
 149    in_progress_matcher: Option<StreamingFuzzyMatcher>,
 150    last_old_text_len: usize,
 151    applied_ranges: Vec<Range<text::Anchor>>,
 152}
 153
 154impl IncrementalEditState {
 155    fn applied_count(&self) -> usize {
 156        self.applied_ranges.len()
 157    }
 158}
 159
 160impl StreamingEditState {
 161    async fn finalize(
 162        &mut self,
 163        input: StreamingEditFileToolInput,
 164        tool: &StreamingEditFileTool,
 165        event_stream: &ToolCallEventStream,
 166        cx: &mut AsyncApp,
 167    ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
 168        let remaining_edits_start_ix = match self {
 169            StreamingEditState::Idle => {
 170                *self = Self::transition_to_buffer_resolved(
 171                    &input.path,
 172                    &input.display_description,
 173                    input.mode.clone(),
 174                    tool,
 175                    event_stream,
 176                    cx,
 177                )
 178                .await?;
 179                0
 180            }
 181            StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(),
 182        };
 183
 184        let StreamingEditState::BufferResolved {
 185            buffer,
 186            old_text,
 187            diff,
 188            abs_path,
 189            ..
 190        } = self
 191        else {
 192            debug_panic!("Invalid state");
 193            return Ok(StreamingEditFileToolOutput::Error {
 194                error: "Internal error. Try to apply the edits again".to_string(),
 195            });
 196        };
 197
 198        let result: anyhow::Result<StreamingEditFileToolOutput> = async {
 199            let action_log = tool
 200                .thread
 201                .read_with(cx, |thread, _cx| thread.action_log().clone())?;
 202
 203            match input.mode {
 204                StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
 205                    action_log.update(cx, |log, cx| {
 206                        log.buffer_created(buffer.clone(), cx);
 207                    });
 208                    let content = input.content.ok_or_else(|| {
 209                        anyhow!("'content' field is required for create and overwrite modes")
 210                    })?;
 211                    cx.update(|cx| {
 212                        buffer.update(cx, |buffer, cx| {
 213                            buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
 214                        });
 215                        action_log.update(cx, |log, cx| {
 216                            log.buffer_edited(buffer.clone(), cx);
 217                        });
 218                    });
 219                }
 220                StreamingEditFileMode::Edit => {
 221                    let edits = input
 222                        .edits
 223                        .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?;
 224
 225                    let remaining_edits = &edits[remaining_edits_start_ix..];
 226                    apply_edits(
 227                        &buffer,
 228                        &action_log,
 229                        remaining_edits,
 230                        &diff,
 231                        event_stream,
 232                        &abs_path,
 233                        cx,
 234                    )?;
 235                }
 236            }
 237
 238            let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
 239                let settings = language_settings::language_settings(
 240                    buffer.language().map(|l| l.name()),
 241                    buffer.file(),
 242                    cx,
 243                );
 244                settings.format_on_save != FormatOnSave::Off
 245            });
 246
 247            if format_on_save_enabled {
 248                action_log.update(cx, |log, cx| {
 249                    log.buffer_edited(buffer.clone(), cx);
 250                });
 251
 252                let format_task = tool.project.update(cx, |project, cx| {
 253                    project.format(
 254                        HashSet::from_iter([buffer.clone()]),
 255                        LspFormatTarget::Buffers,
 256                        false,
 257                        FormatTrigger::Save,
 258                        cx,
 259                    )
 260                });
 261                futures::select! {
 262                    result = format_task.fuse() => { result.log_err(); },
 263                    _ = event_stream.cancelled_by_user().fuse() => {
 264                        anyhow::bail!("Edit cancelled by user");
 265                    }
 266                };
 267            }
 268
 269            let save_task = tool
 270                .project
 271                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
 272            futures::select! {
 273                result = save_task.fuse() => { result?; },
 274                _ = event_stream.cancelled_by_user().fuse() => {
 275                    anyhow::bail!("Edit cancelled by user");
 276                }
 277            };
 278
 279            action_log.update(cx, |log, cx| {
 280                log.buffer_edited(buffer.clone(), cx);
 281            });
 282
 283            if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
 284                buffer.file().and_then(|file| file.disk_state().mtime())
 285            }) {
 286                tool.thread.update(cx, |thread, _| {
 287                    thread
 288                        .file_read_times
 289                        .insert(abs_path.to_path_buf(), new_mtime);
 290                })?;
 291            }
 292
 293            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 294            let (new_text, unified_diff) = cx
 295                .background_spawn({
 296                    let new_snapshot = new_snapshot.clone();
 297                    let old_text = old_text.clone();
 298                    async move {
 299                        let new_text = new_snapshot.text();
 300                        let diff = language::unified_diff(&old_text, &new_text);
 301                        (new_text, diff)
 302                    }
 303                })
 304                .await;
 305
 306            let output = StreamingEditFileToolOutput::Success {
 307                input_path: PathBuf::from(input.path),
 308                new_text,
 309                old_text: old_text.clone(),
 310                diff: unified_diff,
 311            };
 312            Ok(output)
 313        }
 314        .await;
 315        result.map_err(|e| StreamingEditFileToolOutput::Error {
 316            error: e.to_string(),
 317        })
 318    }
 319
 320    async fn process(
 321        &mut self,
 322        partial: StreamingEditFileToolPartialInput,
 323        tool: &StreamingEditFileTool,
 324        event_stream: &ToolCallEventStream,
 325        cx: &mut AsyncApp,
 326    ) -> Result<(), StreamingEditFileToolOutput> {
 327        match self {
 328            Self::Idle => {
 329                if let Some(path_str) = partial.path
 330                    && let Some(display_description) = partial.display_description
 331                    && let Some(mode) = partial.mode
 332                {
 333                    *self = Self::transition_to_buffer_resolved(
 334                        &path_str,
 335                        &display_description,
 336                        mode,
 337                        tool,
 338                        event_stream,
 339                        cx,
 340                    )
 341                    .await?;
 342                }
 343            }
 344            Self::BufferResolved {
 345                abs_path,
 346                buffer,
 347                edit_state,
 348                diff,
 349                ..
 350            } => {
 351                if let Some(edits) = partial.edits {
 352                    Self::process_streaming_edits(
 353                        buffer,
 354                        diff,
 355                        edit_state,
 356                        &edits,
 357                        abs_path,
 358                        tool,
 359                        event_stream,
 360                        cx,
 361                    )?;
 362                }
 363            }
 364        }
 365        Ok(())
 366    }
 367
 368    async fn transition_to_buffer_resolved(
 369        path_str: &str,
 370        display_description: &str,
 371        mode: StreamingEditFileMode,
 372        tool: &StreamingEditFileTool,
 373        event_stream: &ToolCallEventStream,
 374        cx: &mut AsyncApp,
 375    ) -> Result<Self, StreamingEditFileToolOutput> {
 376        let path = PathBuf::from(path_str);
 377        let project_path = cx
 378            .update(|cx| resolve_path(mode, &path, &tool.project, cx))
 379            .map_err(|e| StreamingEditFileToolOutput::Error {
 380                error: e.to_string(),
 381            })?;
 382
 383        let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
 384        else {
 385            return Err(StreamingEditFileToolOutput::Error {
 386                error: format!("File '{path_str}' does not exist"),
 387            });
 388        };
 389
 390        event_stream.update_fields(
 391            ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
 392        );
 393
 394        cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
 395            .await
 396            .map_err(|e| StreamingEditFileToolOutput::Error {
 397                error: e.to_string(),
 398            })?;
 399
 400        let buffer = tool
 401            .project
 402            .update(cx, |project, cx| project.open_buffer(project_path, cx))
 403            .await
 404            .map_err(|e| StreamingEditFileToolOutput::Error {
 405                error: e.to_string(),
 406            })?;
 407
 408        ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
 409
 410        let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
 411        event_stream.update_diff(diff.clone());
 412        let finalize_diff_guard = util::defer(Box::new({
 413            let diff = diff.downgrade();
 414            let mut cx = cx.clone();
 415            move || {
 416                diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
 417            }
 418        }) as Box<dyn FnOnce()>);
 419
 420        let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 421        let old_text = cx
 422            .background_spawn({
 423                let old_snapshot = old_snapshot.clone();
 424                async move { Arc::new(old_snapshot.text()) }
 425            })
 426            .await;
 427
 428        Ok(Self::BufferResolved {
 429            abs_path,
 430            buffer,
 431            old_text,
 432            diff,
 433            edit_state: IncrementalEditState::default(),
 434            _finalize_diff_guard: finalize_diff_guard,
 435        })
 436    }
 437
 438    fn process_streaming_edits(
 439        buffer: &Entity<Buffer>,
 440        diff: &Entity<Diff>,
 441        edit_state: &mut IncrementalEditState,
 442        edits: &[PartialEditOperation],
 443        abs_path: &PathBuf,
 444        tool: &StreamingEditFileTool,
 445        event_stream: &ToolCallEventStream,
 446        cx: &mut AsyncApp,
 447    ) -> Result<(), StreamingEditFileToolOutput> {
 448        if edits.is_empty() {
 449            return Ok(());
 450        }
 451
 452        // Edits at indices applied_count..edits.len()-1 are newly complete
 453        // (a subsequent edit exists, proving the LLM moved on).
 454        // The last edit (edits.len()-1) is potentially still in progress.
 455        let completed_count = edits.len().saturating_sub(1);
 456
 457        // Apply newly-complete edits
 458        while edit_state.applied_count() < completed_count {
 459            let edit_index = edit_state.applied_count();
 460            let partial_edit = &edits[edit_index];
 461
 462            let old_text = partial_edit.old_text.clone().ok_or_else(|| {
 463                StreamingEditFileToolOutput::Error {
 464                    error: format!("Edit at index {} is missing old_text.", edit_index),
 465                }
 466            })?;
 467            let new_text = partial_edit.new_text.clone().unwrap_or_default();
 468
 469            edit_state.in_progress_matcher = None;
 470            edit_state.last_old_text_len = 0;
 471
 472            let edit_op = EditOperation {
 473                old_text: old_text.clone(),
 474                new_text: new_text.clone(),
 475            };
 476
 477            let action_log = tool
 478                .thread
 479                .read_with(cx, |thread, _cx| thread.action_log().clone())
 480                .ok();
 481
 482            // On the first edit, mark the buffer as read
 483            if edit_state.applied_count() == 0 {
 484                if let Some(action_log) = &action_log {
 485                    action_log.update(cx, |log, cx| {
 486                        log.buffer_read(buffer.clone(), cx);
 487                    });
 488                }
 489            }
 490
 491            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 492
 493            let (range, new_text) =
 494                match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) {
 495                    Ok(resolved) => resolved,
 496                    Err(EditResolveError::NotFound) => {
 497                        return Err(StreamingEditFileToolOutput::Error {
 498                            error: format!(
 499                                "Could not find matching text for edit at index {}. \
 500                             The old_text did not match any content in the file. \
 501                             Please read the file again to get the current content.",
 502                                edit_index
 503                            ),
 504                        });
 505                    }
 506                    Err(EditResolveError::Ambiguous(ranges)) => {
 507                        let lines = ranges
 508                            .iter()
 509                            .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
 510                            .collect::<Vec<_>>()
 511                            .join(", ");
 512                        return Err(StreamingEditFileToolOutput::Error {
 513                            error: format!(
 514                                "Edit {} matched multiple locations in the file at lines: {}. \
 515                             Please provide more context in old_text to uniquely \
 516                             identify the location.",
 517                                edit_index, lines
 518                            ),
 519                        });
 520                    }
 521                };
 522
 523            for previous_range in &edit_state.applied_ranges {
 524                let previous_start = previous_range.start.to_offset(&snapshot);
 525                let previous_end = previous_range.end.to_offset(&snapshot);
 526                if range.start < previous_end && previous_start < range.end {
 527                    let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1;
 528                    let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1;
 529                    let later_start_line = snapshot.offset_to_point(range.start).row + 1;
 530                    let later_end_line = snapshot.offset_to_point(range.end).row + 1;
 531                    return Err(StreamingEditFileToolOutput::Error {
 532                        error: format!(
 533                            "Conflicting edit ranges detected: lines {}-{} \
 534                             conflicts with lines {}-{}. Conflicting edit \
 535                             ranges are not allowed, as they would overwrite \
 536                             each other.",
 537                            earlier_start_line, earlier_end_line, later_start_line, later_end_line,
 538                        ),
 539                    });
 540                }
 541            }
 542
 543            let anchor_range =
 544                buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
 545            edit_state.applied_ranges.push(anchor_range);
 546
 547            let line = snapshot.offset_to_point(range.start).row;
 548            event_stream.update_fields(
 549                ToolCallUpdateFields::new()
 550                    .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
 551            );
 552
 553            if let Some(action_log) = action_log {
 554                cx.update(|cx| {
 555                    buffer.update(cx, |buffer, cx| {
 556                        buffer.edit([(range, new_text.as_str())], None, cx);
 557                    });
 558                    action_log.update(cx, |log, cx| {
 559                        log.buffer_edited(buffer.clone(), cx);
 560                    });
 561                });
 562            }
 563        }
 564
 565        // Feed the in-progress last edit's old_text to the matcher for live preview
 566        if let Some(partial_edit) = edits.last() {
 567            if let Some(old_text) = &partial_edit.old_text {
 568                let old_text_len = old_text.len();
 569                if old_text_len > edit_state.last_old_text_len {
 570                    let new_chunk = &old_text[edit_state.last_old_text_len..];
 571
 572                    let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| {
 573                        let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
 574                        StreamingFuzzyMatcher::new(snapshot)
 575                    });
 576
 577                    if let Some(match_range) = matcher.push(new_chunk, None) {
 578                        let anchor_range = buffer.read_with(cx, |buffer, _cx| {
 579                            buffer.anchor_range_between(match_range.clone())
 580                        });
 581                        diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
 582                    }
 583
 584                    edit_state.last_old_text_len = old_text_len;
 585                }
 586            }
 587        }
 588
 589        Ok(())
 590    }
 591}
 592
 593fn ensure_buffer_saved(
 594    buffer: &Entity<Buffer>,
 595    abs_path: &PathBuf,
 596    tool: &StreamingEditFileTool,
 597    cx: &mut AsyncApp,
 598) -> Result<(), StreamingEditFileToolOutput> {
 599    let check_result = tool.thread.update(cx, |thread, cx| {
 600        let last_read = thread.file_read_times.get(abs_path).copied();
 601        let current = buffer
 602            .read(cx)
 603            .file()
 604            .and_then(|file| file.disk_state().mtime());
 605        let dirty = buffer.read(cx).is_dirty();
 606        let has_save = thread.has_tool(SaveFileTool::NAME);
 607        let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
 608        (last_read, current, dirty, has_save, has_restore)
 609    });
 610
 611    let Ok((last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool)) =
 612        check_result
 613    else {
 614        return Ok(());
 615    };
 616
 617    if is_dirty {
 618        let message = match (has_save_tool, has_restore_tool) {
 619            (true, true) => {
 620                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 621                         If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 622                         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."
 623            }
 624            (true, false) => {
 625                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 626                         If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 627                         If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
 628            }
 629            (false, true) => {
 630                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 631                         If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
 632                         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."
 633            }
 634            (false, false) => {
 635                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
 636                         then ask them to save or revert the file manually and inform you when it's ok to proceed."
 637            }
 638        };
 639        return Err(StreamingEditFileToolOutput::Error {
 640            error: message.to_string(),
 641        });
 642    }
 643
 644    if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
 645        if current != last_read {
 646            return Err(StreamingEditFileToolOutput::Error {
 647                error: "The file has been modified since you last read it. \
 648                             Please read the file again to get the current state before editing it."
 649                    .to_string(),
 650            });
 651        }
 652    }
 653
 654    Ok(())
 655}
 656
 657#[derive(Debug, Serialize, Deserialize)]
 658#[serde(untagged)]
 659pub enum StreamingEditFileToolOutput {
 660    Success {
 661        #[serde(alias = "original_path")]
 662        input_path: PathBuf,
 663        new_text: String,
 664        old_text: Arc<String>,
 665        #[serde(default)]
 666        diff: String,
 667    },
 668    Error {
 669        error: String,
 670    },
 671}
 672
 673impl std::fmt::Display for StreamingEditFileToolOutput {
 674    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 675        match self {
 676            StreamingEditFileToolOutput::Success {
 677                diff, input_path, ..
 678            } => {
 679                if diff.is_empty() {
 680                    write!(f, "No edits were made.")
 681                } else {
 682                    write!(
 683                        f,
 684                        "Edited {}:\n\n```diff\n{diff}\n```",
 685                        input_path.display()
 686                    )
 687                }
 688            }
 689            StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),
 690        }
 691    }
 692}
 693
 694impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
 695    fn from(output: StreamingEditFileToolOutput) -> Self {
 696        output.to_string().into()
 697    }
 698}
 699
 700pub struct StreamingEditFileTool {
 701    thread: WeakEntity<Thread>,
 702    language_registry: Arc<LanguageRegistry>,
 703    project: Entity<Project>,
 704}
 705
 706impl StreamingEditFileTool {
 707    pub fn new(
 708        project: Entity<Project>,
 709        thread: WeakEntity<Thread>,
 710        language_registry: Arc<LanguageRegistry>,
 711    ) -> Self {
 712        Self {
 713            project,
 714            thread,
 715            language_registry,
 716        }
 717    }
 718
 719    fn authorize(
 720        &self,
 721        path: &PathBuf,
 722        description: &str,
 723        event_stream: &ToolCallEventStream,
 724        cx: &mut App,
 725    ) -> Task<Result<()>> {
 726        super::tool_permissions::authorize_file_edit(
 727            EditFileTool::NAME,
 728            path,
 729            description,
 730            &self.thread,
 731            event_stream,
 732            cx,
 733        )
 734    }
 735}
 736
 737impl AgentTool for StreamingEditFileTool {
 738    type Input = StreamingEditFileToolInput;
 739    type Output = StreamingEditFileToolOutput;
 740
 741    const NAME: &'static str = "streaming_edit_file";
 742
 743    fn supports_input_streaming() -> bool {
 744        true
 745    }
 746
 747    fn kind() -> acp::ToolKind {
 748        acp::ToolKind::Edit
 749    }
 750
 751    fn initial_title(
 752        &self,
 753        input: Result<Self::Input, serde_json::Value>,
 754        cx: &mut App,
 755    ) -> SharedString {
 756        match input {
 757            Ok(input) => self
 758                .project
 759                .read(cx)
 760                .find_project_path(&input.path, cx)
 761                .and_then(|project_path| {
 762                    self.project
 763                        .read(cx)
 764                        .short_full_path_for_project_path(&project_path, cx)
 765                })
 766                .unwrap_or(input.path)
 767                .into(),
 768            Err(raw_input) => {
 769                if let Some(input) =
 770                    serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()
 771                {
 772                    let path = input.path.unwrap_or_default();
 773                    let path = path.trim();
 774                    if !path.is_empty() {
 775                        return self
 776                            .project
 777                            .read(cx)
 778                            .find_project_path(&path, cx)
 779                            .and_then(|project_path| {
 780                                self.project
 781                                    .read(cx)
 782                                    .short_full_path_for_project_path(&project_path, cx)
 783                            })
 784                            .unwrap_or_else(|| path.to_string())
 785                            .into();
 786                    }
 787
 788                    let description = input.display_description.unwrap_or_default();
 789                    let description = description.trim();
 790                    if !description.is_empty() {
 791                        return description.to_string().into();
 792                    }
 793                }
 794
 795                DEFAULT_UI_TEXT.into()
 796            }
 797        }
 798    }
 799
 800    fn run(
 801        self: Arc<Self>,
 802        mut input: ToolInput<Self::Input>,
 803        event_stream: ToolCallEventStream,
 804        cx: &mut App,
 805    ) -> Task<Result<Self::Output, Self::Output>> {
 806        cx.spawn(async move |cx: &mut AsyncApp| {
 807            let mut state = StreamingEditState::Idle;
 808            loop {
 809                futures::select! {
 810                    partial = input.recv_partial().fuse() => {
 811                        let Some(partial_value) = partial else { break };
 812                        if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
 813                            state.process(parsed, &self, &event_stream, cx).await?;
 814                        }
 815                    }
 816                    _ = event_stream.cancelled_by_user().fuse() => {
 817                        return Err(StreamingEditFileToolOutput::Error {
 818                            error: "Edit cancelled by user".to_string(),
 819                        });
 820                    }
 821                }
 822            }
 823            let full_input =
 824                input
 825                    .recv()
 826                    .await
 827                    .map_err(|e| StreamingEditFileToolOutput::Error {
 828                        error: format!("Failed to receive tool input: {e}"),
 829                    })?;
 830
 831            state.finalize(full_input, &self, &event_stream, cx).await
 832        })
 833    }
 834
 835    fn replay(
 836        &self,
 837        _input: Self::Input,
 838        output: Self::Output,
 839        event_stream: ToolCallEventStream,
 840        cx: &mut App,
 841    ) -> Result<()> {
 842        match output {
 843            StreamingEditFileToolOutput::Success {
 844                input_path,
 845                old_text,
 846                new_text,
 847                ..
 848            } => {
 849                event_stream.update_diff(cx.new(|cx| {
 850                    Diff::finalized(
 851                        input_path.to_string_lossy().into_owned(),
 852                        Some(old_text.to_string()),
 853                        new_text,
 854                        self.language_registry.clone(),
 855                        cx,
 856                    )
 857                }));
 858                Ok(())
 859            }
 860            StreamingEditFileToolOutput::Error { .. } => Ok(()),
 861        }
 862    }
 863}
 864
 865fn apply_edits(
 866    buffer: &Entity<language::Buffer>,
 867    action_log: &Entity<action_log::ActionLog>,
 868    edits: &[EditOperation],
 869    diff: &Entity<Diff>,
 870    event_stream: &ToolCallEventStream,
 871    abs_path: &PathBuf,
 872    cx: &mut AsyncApp,
 873) -> Result<()> {
 874    let mut failed_edits = Vec::new();
 875    let mut ambiguous_edits = Vec::new();
 876    let mut resolved_edits: Vec<(Range<usize>, String)> = Vec::new();
 877
 878    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 879    for (index, edit) in edits.iter().enumerate() {
 880        match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) {
 881            Ok((range, new_text)) => {
 882                resolved_edits.push((range, new_text));
 883            }
 884            Err(EditResolveError::NotFound) => {
 885                failed_edits.push(index);
 886            }
 887            Err(EditResolveError::Ambiguous(ranges)) => {
 888                ambiguous_edits.push((index, ranges));
 889            }
 890        }
 891    }
 892
 893    if !failed_edits.is_empty() {
 894        let indices = failed_edits
 895            .iter()
 896            .map(|i| i.to_string())
 897            .collect::<Vec<_>>()
 898            .join(", ");
 899        anyhow::bail!(
 900            "Could not find matching text for edit(s) at index(es): {}. \
 901             The old_text did not match any content in the file. \
 902             Please read the file again to get the current content.",
 903            indices
 904        );
 905    }
 906
 907    if !ambiguous_edits.is_empty() {
 908        let details: Vec<String> = ambiguous_edits
 909            .iter()
 910            .map(|(index, ranges)| {
 911                let lines = ranges
 912                    .iter()
 913                    .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
 914                    .collect::<Vec<_>>()
 915                    .join(", ");
 916                format!("edit {}: matches at lines {}", index, lines)
 917            })
 918            .collect();
 919        anyhow::bail!(
 920            "Some edits matched multiple locations in the file:\n{}. \
 921             Please provide more context in old_text to uniquely identify the location.",
 922            details.join("\n")
 923        );
 924    }
 925
 926    let mut edits_sorted = resolved_edits;
 927    edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start));
 928
 929    if let Some((first_range, _)) = edits_sorted.first() {
 930        let line = snapshot.offset_to_point(first_range.start).row;
 931        event_stream.update_fields(
 932            ToolCallUpdateFields::new()
 933                .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
 934        );
 935    }
 936
 937    for window in edits_sorted.windows(2) {
 938        if let [(earlier_range, _), (later_range, _)] = window
 939            && (earlier_range.end > later_range.start || earlier_range.start == later_range.start)
 940        {
 941            let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1;
 942            let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1;
 943            let later_start_line = snapshot.offset_to_point(later_range.start).row + 1;
 944            let later_end_line = snapshot.offset_to_point(later_range.end).row + 1;
 945            anyhow::bail!(
 946                "Conflicting edit ranges detected: lines {}-{} conflicts with lines {}-{}. \
 947                 Conflicting edit ranges are not allowed, as they would overwrite each other.",
 948                earlier_start_line,
 949                earlier_end_line,
 950                later_start_line,
 951                later_end_line,
 952            );
 953        }
 954    }
 955
 956    if !edits_sorted.is_empty() {
 957        cx.update(|cx| {
 958            buffer.update(cx, |buffer, cx| {
 959                buffer.edit(
 960                    edits_sorted
 961                        .iter()
 962                        .map(|(range, new_text)| (range.clone(), new_text.as_str())),
 963                    None,
 964                    cx,
 965                );
 966            });
 967            action_log.update(cx, |log, cx| {
 968                log.buffer_edited(buffer.clone(), cx);
 969            });
 970        });
 971    }
 972
 973    Ok(())
 974}
 975
 976enum EditResolveError {
 977    NotFound,
 978    Ambiguous(Vec<Range<usize>>),
 979}
 980
 981/// Resolves an edit operation by finding matching text in the buffer,
 982/// reveals the matched range in the diff view, and returns the resolved
 983/// range and replacement text.
 984fn resolve_and_reveal_edit(
 985    buffer: &Entity<Buffer>,
 986    diff: &Entity<Diff>,
 987    snapshot: &BufferSnapshot,
 988    edit: &EditOperation,
 989    cx: &mut AsyncApp,
 990) -> std::result::Result<(Range<usize>, String), EditResolveError> {
 991    let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
 992    matcher.push(&edit.old_text, None);
 993    let matches = matcher.finish();
 994    if matches.is_empty() {
 995        return Err(EditResolveError::NotFound);
 996    }
 997    if matches.len() > 1 {
 998        return Err(EditResolveError::Ambiguous(matches));
 999    }
1000
1001    let range = matches.into_iter().next().expect("checked len above");
1002
1003    let anchor_range =
1004        buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
1005    diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
1006
1007    Ok((range, edit.new_text.clone()))
1008}
1009
1010fn resolve_path(
1011    mode: StreamingEditFileMode,
1012    path: &PathBuf,
1013    project: &Entity<Project>,
1014    cx: &mut App,
1015) -> Result<ProjectPath> {
1016    let project = project.read(cx);
1017
1018    match mode {
1019        StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => {
1020            let path = project
1021                .find_project_path(&path, cx)
1022                .context("Can't edit file: path not found")?;
1023
1024            let entry = project
1025                .entry_for_path(&path, cx)
1026                .context("Can't edit file: path not found")?;
1027
1028            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
1029            Ok(path)
1030        }
1031
1032        StreamingEditFileMode::Create => {
1033            if let Some(path) = project.find_project_path(&path, cx) {
1034                anyhow::ensure!(
1035                    project.entry_for_path(&path, cx).is_none(),
1036                    "Can't create file: file already exists"
1037                );
1038            }
1039
1040            let parent_path = path.parent().context("Can't create file: incorrect path")?;
1041
1042            let parent_project_path = project.find_project_path(&parent_path, cx);
1043
1044            let parent_entry = parent_project_path
1045                .as_ref()
1046                .and_then(|path| project.entry_for_path(path, cx))
1047                .context("Can't create file: parent directory doesn't exist")?;
1048
1049            anyhow::ensure!(
1050                parent_entry.is_dir(),
1051                "Can't create file: parent is not a directory"
1052            );
1053
1054            let file_name = path
1055                .file_name()
1056                .and_then(|file_name| file_name.to_str())
1057                .and_then(|file_name| RelPath::unix(file_name).ok())
1058                .context("Can't create file: invalid filename")?;
1059
1060            let new_file_path = parent_project_path.map(|parent| ProjectPath {
1061                path: parent.path.join(file_name),
1062                ..parent
1063            });
1064
1065            new_file_path.context("Can't create file")
1066        }
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::{ContextServerRegistry, Templates, ToolInputSender};
1074    use fs::Fs as _;
1075    use futures::StreamExt as _;
1076    use gpui::{TestAppContext, UpdateGlobal};
1077    use language_model::fake_provider::FakeLanguageModel;
1078    use prompt_store::ProjectContext;
1079    use serde_json::json;
1080    use settings::Settings;
1081    use settings::SettingsStore;
1082    use util::path;
1083    use util::rel_path::rel_path;
1084
1085    #[gpui::test]
1086    async fn test_streaming_edit_create_file(cx: &mut TestAppContext) {
1087        init_test(cx);
1088
1089        let fs = project::FakeFs::new(cx.executor());
1090        fs.insert_tree("/root", json!({"dir": {}})).await;
1091        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1092        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1093        let context_server_registry =
1094            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1095        let model = Arc::new(FakeLanguageModel::default());
1096        let thread = cx.new(|cx| {
1097            crate::Thread::new(
1098                project.clone(),
1099                cx.new(|_cx| ProjectContext::default()),
1100                context_server_registry,
1101                Templates::new(),
1102                Some(model),
1103                cx,
1104            )
1105        });
1106
1107        let result = cx
1108            .update(|cx| {
1109                let input = StreamingEditFileToolInput {
1110                    display_description: "Create new file".into(),
1111                    path: "root/dir/new_file.txt".into(),
1112                    mode: StreamingEditFileMode::Create,
1113                    content: Some("Hello, World!".into()),
1114                    edits: None,
1115                };
1116                Arc::new(StreamingEditFileTool::new(
1117                    project.clone(),
1118                    thread.downgrade(),
1119                    language_registry,
1120                ))
1121                .run(
1122                    ToolInput::resolved(input),
1123                    ToolCallEventStream::test().0,
1124                    cx,
1125                )
1126            })
1127            .await;
1128
1129        let StreamingEditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else {
1130            panic!("expected success");
1131        };
1132        assert_eq!(new_text, "Hello, World!");
1133        assert!(!diff.is_empty());
1134    }
1135
1136    #[gpui::test]
1137    async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) {
1138        init_test(cx);
1139
1140        let fs = project::FakeFs::new(cx.executor());
1141        fs.insert_tree("/root", json!({"file.txt": "old content"}))
1142            .await;
1143        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1144        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1145        let context_server_registry =
1146            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1147        let model = Arc::new(FakeLanguageModel::default());
1148        let thread = cx.new(|cx| {
1149            crate::Thread::new(
1150                project.clone(),
1151                cx.new(|_cx| ProjectContext::default()),
1152                context_server_registry,
1153                Templates::new(),
1154                Some(model),
1155                cx,
1156            )
1157        });
1158
1159        let result = cx
1160            .update(|cx| {
1161                let input = StreamingEditFileToolInput {
1162                    display_description: "Overwrite file".into(),
1163                    path: "root/file.txt".into(),
1164                    mode: StreamingEditFileMode::Overwrite,
1165                    content: Some("new content".into()),
1166                    edits: None,
1167                };
1168                Arc::new(StreamingEditFileTool::new(
1169                    project.clone(),
1170                    thread.downgrade(),
1171                    language_registry,
1172                ))
1173                .run(
1174                    ToolInput::resolved(input),
1175                    ToolCallEventStream::test().0,
1176                    cx,
1177                )
1178            })
1179            .await;
1180
1181        let StreamingEditFileToolOutput::Success {
1182            new_text, old_text, ..
1183        } = result.unwrap()
1184        else {
1185            panic!("expected success");
1186        };
1187        assert_eq!(new_text, "new content");
1188        assert_eq!(*old_text, "old content");
1189    }
1190
1191    #[gpui::test]
1192    async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) {
1193        init_test(cx);
1194
1195        let fs = project::FakeFs::new(cx.executor());
1196        fs.insert_tree(
1197            "/root",
1198            json!({
1199                "file.txt": "line 1\nline 2\nline 3\n"
1200            }),
1201        )
1202        .await;
1203        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1204        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1205        let context_server_registry =
1206            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1207        let model = Arc::new(FakeLanguageModel::default());
1208        let thread = cx.new(|cx| {
1209            crate::Thread::new(
1210                project.clone(),
1211                cx.new(|_cx| ProjectContext::default()),
1212                context_server_registry,
1213                Templates::new(),
1214                Some(model),
1215                cx,
1216            )
1217        });
1218
1219        let result = cx
1220            .update(|cx| {
1221                let input = StreamingEditFileToolInput {
1222                    display_description: "Edit lines".into(),
1223                    path: "root/file.txt".into(),
1224                    mode: StreamingEditFileMode::Edit,
1225                    content: None,
1226                    edits: Some(vec![EditOperation {
1227                        old_text: "line 2".into(),
1228                        new_text: "modified line 2".into(),
1229                    }]),
1230                };
1231                Arc::new(StreamingEditFileTool::new(
1232                    project.clone(),
1233                    thread.downgrade(),
1234                    language_registry,
1235                ))
1236                .run(
1237                    ToolInput::resolved(input),
1238                    ToolCallEventStream::test().0,
1239                    cx,
1240                )
1241            })
1242            .await;
1243
1244        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1245            panic!("expected success");
1246        };
1247        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1248    }
1249
1250    #[gpui::test]
1251    async fn test_streaming_edit_multiple_nonoverlapping_edits(cx: &mut TestAppContext) {
1252        init_test(cx);
1253
1254        let fs = project::FakeFs::new(cx.executor());
1255        fs.insert_tree(
1256            "/root",
1257            json!({
1258                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1259            }),
1260        )
1261        .await;
1262        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1263        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1264        let context_server_registry =
1265            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1266        let model = Arc::new(FakeLanguageModel::default());
1267        let thread = cx.new(|cx| {
1268            crate::Thread::new(
1269                project.clone(),
1270                cx.new(|_cx| ProjectContext::default()),
1271                context_server_registry,
1272                Templates::new(),
1273                Some(model),
1274                cx,
1275            )
1276        });
1277
1278        let result = cx
1279            .update(|cx| {
1280                let input = StreamingEditFileToolInput {
1281                    display_description: "Edit multiple lines".into(),
1282                    path: "root/file.txt".into(),
1283                    mode: StreamingEditFileMode::Edit,
1284                    content: None,
1285                    edits: Some(vec![
1286                        EditOperation {
1287                            old_text: "line 5".into(),
1288                            new_text: "modified line 5".into(),
1289                        },
1290                        EditOperation {
1291                            old_text: "line 1".into(),
1292                            new_text: "modified line 1".into(),
1293                        },
1294                    ]),
1295                };
1296                Arc::new(StreamingEditFileTool::new(
1297                    project.clone(),
1298                    thread.downgrade(),
1299                    language_registry,
1300                ))
1301                .run(
1302                    ToolInput::resolved(input),
1303                    ToolCallEventStream::test().0,
1304                    cx,
1305                )
1306            })
1307            .await;
1308
1309        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1310            panic!("expected success");
1311        };
1312        assert_eq!(
1313            new_text,
1314            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1315        );
1316    }
1317
1318    #[gpui::test]
1319    async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) {
1320        init_test(cx);
1321
1322        let fs = project::FakeFs::new(cx.executor());
1323        fs.insert_tree(
1324            "/root",
1325            json!({
1326                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1327            }),
1328        )
1329        .await;
1330        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1331        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1332        let context_server_registry =
1333            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1334        let model = Arc::new(FakeLanguageModel::default());
1335        let thread = cx.new(|cx| {
1336            crate::Thread::new(
1337                project.clone(),
1338                cx.new(|_cx| ProjectContext::default()),
1339                context_server_registry,
1340                Templates::new(),
1341                Some(model),
1342                cx,
1343            )
1344        });
1345
1346        let result = cx
1347            .update(|cx| {
1348                let input = StreamingEditFileToolInput {
1349                    display_description: "Edit adjacent lines".into(),
1350                    path: "root/file.txt".into(),
1351                    mode: StreamingEditFileMode::Edit,
1352                    content: None,
1353                    edits: Some(vec![
1354                        EditOperation {
1355                            old_text: "line 2".into(),
1356                            new_text: "modified line 2".into(),
1357                        },
1358                        EditOperation {
1359                            old_text: "line 3".into(),
1360                            new_text: "modified line 3".into(),
1361                        },
1362                    ]),
1363                };
1364                Arc::new(StreamingEditFileTool::new(
1365                    project.clone(),
1366                    thread.downgrade(),
1367                    language_registry,
1368                ))
1369                .run(
1370                    ToolInput::resolved(input),
1371                    ToolCallEventStream::test().0,
1372                    cx,
1373                )
1374            })
1375            .await;
1376
1377        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1378            panic!("expected success");
1379        };
1380        assert_eq!(
1381            new_text,
1382            "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n"
1383        );
1384    }
1385
1386    #[gpui::test]
1387    async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) {
1388        init_test(cx);
1389
1390        let fs = project::FakeFs::new(cx.executor());
1391        fs.insert_tree(
1392            "/root",
1393            json!({
1394                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1395            }),
1396        )
1397        .await;
1398        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1399        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1400        let context_server_registry =
1401            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1402        let model = Arc::new(FakeLanguageModel::default());
1403        let thread = cx.new(|cx| {
1404            crate::Thread::new(
1405                project.clone(),
1406                cx.new(|_cx| ProjectContext::default()),
1407                context_server_registry,
1408                Templates::new(),
1409                Some(model),
1410                cx,
1411            )
1412        });
1413
1414        let result = cx
1415            .update(|cx| {
1416                let input = StreamingEditFileToolInput {
1417                    display_description: "Edit multiple lines in ascending order".into(),
1418                    path: "root/file.txt".into(),
1419                    mode: StreamingEditFileMode::Edit,
1420                    content: None,
1421                    edits: Some(vec![
1422                        EditOperation {
1423                            old_text: "line 1".into(),
1424                            new_text: "modified line 1".into(),
1425                        },
1426                        EditOperation {
1427                            old_text: "line 5".into(),
1428                            new_text: "modified line 5".into(),
1429                        },
1430                    ]),
1431                };
1432                Arc::new(StreamingEditFileTool::new(
1433                    project.clone(),
1434                    thread.downgrade(),
1435                    language_registry,
1436                ))
1437                .run(
1438                    ToolInput::resolved(input),
1439                    ToolCallEventStream::test().0,
1440                    cx,
1441                )
1442            })
1443            .await;
1444
1445        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1446            panic!("expected success");
1447        };
1448        assert_eq!(
1449            new_text,
1450            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1451        );
1452    }
1453
1454    #[gpui::test]
1455    async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) {
1456        init_test(cx);
1457
1458        let fs = project::FakeFs::new(cx.executor());
1459        fs.insert_tree("/root", json!({})).await;
1460        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1461        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1462        let context_server_registry =
1463            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1464        let model = Arc::new(FakeLanguageModel::default());
1465        let thread = cx.new(|cx| {
1466            crate::Thread::new(
1467                project.clone(),
1468                cx.new(|_cx| ProjectContext::default()),
1469                context_server_registry,
1470                Templates::new(),
1471                Some(model),
1472                cx,
1473            )
1474        });
1475
1476        let result = cx
1477            .update(|cx| {
1478                let input = StreamingEditFileToolInput {
1479                    display_description: "Some edit".into(),
1480                    path: "root/nonexistent_file.txt".into(),
1481                    mode: StreamingEditFileMode::Edit,
1482                    content: None,
1483                    edits: Some(vec![EditOperation {
1484                        old_text: "foo".into(),
1485                        new_text: "bar".into(),
1486                    }]),
1487                };
1488                Arc::new(StreamingEditFileTool::new(
1489                    project,
1490                    thread.downgrade(),
1491                    language_registry,
1492                ))
1493                .run(
1494                    ToolInput::resolved(input),
1495                    ToolCallEventStream::test().0,
1496                    cx,
1497                )
1498            })
1499            .await;
1500
1501        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1502            panic!("expected error");
1503        };
1504        assert_eq!(error, "Can't edit file: path not found");
1505    }
1506
1507    #[gpui::test]
1508    async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) {
1509        init_test(cx);
1510
1511        let fs = project::FakeFs::new(cx.executor());
1512        fs.insert_tree("/root", json!({"file.txt": "hello world"}))
1513            .await;
1514        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1515        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1516        let context_server_registry =
1517            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1518        let model = Arc::new(FakeLanguageModel::default());
1519        let thread = cx.new(|cx| {
1520            crate::Thread::new(
1521                project.clone(),
1522                cx.new(|_cx| ProjectContext::default()),
1523                context_server_registry,
1524                Templates::new(),
1525                Some(model),
1526                cx,
1527            )
1528        });
1529
1530        let result = cx
1531            .update(|cx| {
1532                let input = StreamingEditFileToolInput {
1533                    display_description: "Edit file".into(),
1534                    path: "root/file.txt".into(),
1535                    mode: StreamingEditFileMode::Edit,
1536                    content: None,
1537                    edits: Some(vec![EditOperation {
1538                        old_text: "nonexistent text that is not in the file".into(),
1539                        new_text: "replacement".into(),
1540                    }]),
1541                };
1542                Arc::new(StreamingEditFileTool::new(
1543                    project,
1544                    thread.downgrade(),
1545                    language_registry,
1546                ))
1547                .run(
1548                    ToolInput::resolved(input),
1549                    ToolCallEventStream::test().0,
1550                    cx,
1551                )
1552            })
1553            .await;
1554
1555        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1556            panic!("expected error");
1557        };
1558        assert!(
1559            error.contains("Could not find matching text"),
1560            "Expected error containing 'Could not find matching text' but got: {error}"
1561        );
1562    }
1563
1564    #[gpui::test]
1565    async fn test_streaming_edit_overlapping_edits_out_of_order(cx: &mut TestAppContext) {
1566        init_test(cx);
1567
1568        let fs = project::FakeFs::new(cx.executor());
1569        // Multi-line file so the line-based fuzzy matcher can resolve each edit.
1570        fs.insert_tree(
1571            "/root",
1572            json!({
1573                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1574            }),
1575        )
1576        .await;
1577        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1578        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1579        let context_server_registry =
1580            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1581        let model = Arc::new(FakeLanguageModel::default());
1582        let thread = cx.new(|cx| {
1583            crate::Thread::new(
1584                project.clone(),
1585                cx.new(|_cx| ProjectContext::default()),
1586                context_server_registry,
1587                Templates::new(),
1588                Some(model),
1589                cx,
1590            )
1591        });
1592
1593        // Edit A spans lines 3-4, edit B spans lines 2-3. They overlap on
1594        // "line 3" and are given in descending file order so the ascending
1595        // sort must reorder them before the pairwise overlap check can
1596        // detect them correctly.
1597        let result = cx
1598            .update(|cx| {
1599                let input = StreamingEditFileToolInput {
1600                    display_description: "Overlapping edits".into(),
1601                    path: "root/file.txt".into(),
1602                    mode: StreamingEditFileMode::Edit,
1603                    content: None,
1604                    edits: Some(vec![
1605                        EditOperation {
1606                            old_text: "line 3\nline 4".into(),
1607                            new_text: "SECOND".into(),
1608                        },
1609                        EditOperation {
1610                            old_text: "line 2\nline 3".into(),
1611                            new_text: "FIRST".into(),
1612                        },
1613                    ]),
1614                };
1615                Arc::new(StreamingEditFileTool::new(
1616                    project,
1617                    thread.downgrade(),
1618                    language_registry,
1619                ))
1620                .run(
1621                    ToolInput::resolved(input),
1622                    ToolCallEventStream::test().0,
1623                    cx,
1624                )
1625            })
1626            .await;
1627
1628        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1629            panic!("expected error");
1630        };
1631        assert!(
1632            error.contains("Conflicting edit ranges detected"),
1633            "Expected 'Conflicting edit ranges detected' but got: {error}"
1634        );
1635    }
1636
1637    #[gpui::test]
1638    async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {
1639        init_test(cx);
1640
1641        let fs = project::FakeFs::new(cx.executor());
1642        fs.insert_tree(
1643            "/root",
1644            json!({
1645                "file.txt": "line 1\nline 2\nline 3\n"
1646            }),
1647        )
1648        .await;
1649        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1650        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1651        let context_server_registry =
1652            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1653        let model = Arc::new(FakeLanguageModel::default());
1654        let thread = cx.new(|cx| {
1655            crate::Thread::new(
1656                project.clone(),
1657                cx.new(|_cx| ProjectContext::default()),
1658                context_server_registry,
1659                Templates::new(),
1660                Some(model),
1661                cx,
1662            )
1663        });
1664
1665        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1666        let (event_stream, _receiver) = ToolCallEventStream::test();
1667
1668        let tool = Arc::new(StreamingEditFileTool::new(
1669            project.clone(),
1670            thread.downgrade(),
1671            language_registry,
1672        ));
1673
1674        let task = cx.update(|cx| tool.run(input, event_stream, cx));
1675
1676        // Send partials simulating LLM streaming: description first, then path, then mode
1677        sender.send_partial(json!({"display_description": "Edit lines"}));
1678        cx.run_until_parked();
1679
1680        sender.send_partial(json!({
1681            "display_description": "Edit lines",
1682            "path": "root/file.txt"
1683        }));
1684        cx.run_until_parked();
1685
1686        // Path is NOT yet complete because mode hasn't appeared — no buffer open yet
1687        sender.send_partial(json!({
1688            "display_description": "Edit lines",
1689            "path": "root/file.txt",
1690            "mode": "edit"
1691        }));
1692        cx.run_until_parked();
1693
1694        // Now send the final complete input
1695        sender.send_final(json!({
1696            "display_description": "Edit lines",
1697            "path": "root/file.txt",
1698            "mode": "edit",
1699            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
1700        }));
1701
1702        let result = task.await;
1703        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1704            panic!("expected success");
1705        };
1706        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1707    }
1708
1709    #[gpui::test]
1710    async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) {
1711        init_test(cx);
1712
1713        let fs = project::FakeFs::new(cx.executor());
1714        fs.insert_tree(
1715            "/root",
1716            json!({
1717                "file.txt": "hello world"
1718            }),
1719        )
1720        .await;
1721        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1722        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1723        let context_server_registry =
1724            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1725        let model = Arc::new(FakeLanguageModel::default());
1726        let thread = cx.new(|cx| {
1727            crate::Thread::new(
1728                project.clone(),
1729                cx.new(|_cx| ProjectContext::default()),
1730                context_server_registry,
1731                Templates::new(),
1732                Some(model),
1733                cx,
1734            )
1735        });
1736
1737        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1738        let (event_stream, _receiver) = ToolCallEventStream::test();
1739
1740        let tool = Arc::new(StreamingEditFileTool::new(
1741            project.clone(),
1742            thread.downgrade(),
1743            language_registry,
1744        ));
1745
1746        let task = cx.update(|cx| tool.run(input, event_stream, cx));
1747
1748        // Send partial with path but NO mode — path should NOT be treated as complete
1749        sender.send_partial(json!({
1750            "display_description": "Overwrite file",
1751            "path": "root/file"
1752        }));
1753        cx.run_until_parked();
1754
1755        // Now the path grows and mode appears
1756        sender.send_partial(json!({
1757            "display_description": "Overwrite file",
1758            "path": "root/file.txt",
1759            "mode": "overwrite"
1760        }));
1761        cx.run_until_parked();
1762
1763        // Send final
1764        sender.send_final(json!({
1765            "display_description": "Overwrite file",
1766            "path": "root/file.txt",
1767            "mode": "overwrite",
1768            "content": "new content"
1769        }));
1770
1771        let result = task.await;
1772        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1773            panic!("expected success");
1774        };
1775        assert_eq!(new_text, "new content");
1776    }
1777
1778    #[gpui::test]
1779    async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) {
1780        init_test(cx);
1781
1782        let fs = project::FakeFs::new(cx.executor());
1783        fs.insert_tree(
1784            "/root",
1785            json!({
1786                "file.txt": "hello world"
1787            }),
1788        )
1789        .await;
1790        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1791        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1792        let context_server_registry =
1793            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1794        let model = Arc::new(FakeLanguageModel::default());
1795        let thread = cx.new(|cx| {
1796            crate::Thread::new(
1797                project.clone(),
1798                cx.new(|_cx| ProjectContext::default()),
1799                context_server_registry,
1800                Templates::new(),
1801                Some(model),
1802                cx,
1803            )
1804        });
1805
1806        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1807        let (event_stream, _receiver, mut cancellation_tx) =
1808            ToolCallEventStream::test_with_cancellation();
1809
1810        let tool = Arc::new(StreamingEditFileTool::new(
1811            project.clone(),
1812            thread.downgrade(),
1813            language_registry,
1814        ));
1815
1816        let task = cx.update(|cx| tool.run(input, event_stream, cx));
1817
1818        // Send a partial
1819        sender.send_partial(json!({"display_description": "Edit"}));
1820        cx.run_until_parked();
1821
1822        // Cancel during streaming
1823        ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx);
1824        cx.run_until_parked();
1825
1826        // The sender is still alive so the partial loop should detect cancellation
1827        // We need to drop the sender to also unblock recv() if the loop didn't catch it
1828        drop(sender);
1829
1830        let result = task.await;
1831        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1832            panic!("expected error");
1833        };
1834        assert!(
1835            error.contains("cancelled"),
1836            "Expected cancellation error but got: {error}"
1837        );
1838    }
1839
1840    #[gpui::test]
1841    async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) {
1842        init_test(cx);
1843
1844        let fs = project::FakeFs::new(cx.executor());
1845        fs.insert_tree(
1846            "/root",
1847            json!({
1848                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1849            }),
1850        )
1851        .await;
1852        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1853        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1854        let context_server_registry =
1855            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1856        let model = Arc::new(FakeLanguageModel::default());
1857        let thread = cx.new(|cx| {
1858            crate::Thread::new(
1859                project.clone(),
1860                cx.new(|_cx| ProjectContext::default()),
1861                context_server_registry,
1862                Templates::new(),
1863                Some(model),
1864                cx,
1865            )
1866        });
1867
1868        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1869        let (event_stream, _receiver) = ToolCallEventStream::test();
1870
1871        let tool = Arc::new(StreamingEditFileTool::new(
1872            project.clone(),
1873            thread.downgrade(),
1874            language_registry,
1875        ));
1876
1877        let task = cx.update(|cx| tool.run(input, event_stream, cx));
1878
1879        // Simulate fine-grained streaming of the JSON
1880        sender.send_partial(json!({"display_description": "Edit multiple"}));
1881        cx.run_until_parked();
1882
1883        sender.send_partial(json!({
1884            "display_description": "Edit multiple lines",
1885            "path": "root/file.txt"
1886        }));
1887        cx.run_until_parked();
1888
1889        sender.send_partial(json!({
1890            "display_description": "Edit multiple lines",
1891            "path": "root/file.txt",
1892            "mode": "edit"
1893        }));
1894        cx.run_until_parked();
1895
1896        sender.send_partial(json!({
1897            "display_description": "Edit multiple lines",
1898            "path": "root/file.txt",
1899            "mode": "edit",
1900            "edits": [{"old_text": "line 1"}]
1901        }));
1902        cx.run_until_parked();
1903
1904        sender.send_partial(json!({
1905            "display_description": "Edit multiple lines",
1906            "path": "root/file.txt",
1907            "mode": "edit",
1908            "edits": [
1909                {"old_text": "line 1", "new_text": "modified line 1"},
1910                {"old_text": "line 5"}
1911            ]
1912        }));
1913        cx.run_until_parked();
1914
1915        // Send final complete input
1916        sender.send_final(json!({
1917            "display_description": "Edit multiple lines",
1918            "path": "root/file.txt",
1919            "mode": "edit",
1920            "edits": [
1921                {"old_text": "line 1", "new_text": "modified line 1"},
1922                {"old_text": "line 5", "new_text": "modified line 5"}
1923            ]
1924        }));
1925
1926        let result = task.await;
1927        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1928            panic!("expected success");
1929        };
1930        assert_eq!(
1931            new_text,
1932            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1933        );
1934    }
1935
1936    #[gpui::test]
1937    async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) {
1938        init_test(cx);
1939
1940        let fs = project::FakeFs::new(cx.executor());
1941        fs.insert_tree("/root", json!({"dir": {}})).await;
1942        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1943        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1944        let context_server_registry =
1945            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1946        let model = Arc::new(FakeLanguageModel::default());
1947        let thread = cx.new(|cx| {
1948            crate::Thread::new(
1949                project.clone(),
1950                cx.new(|_cx| ProjectContext::default()),
1951                context_server_registry,
1952                Templates::new(),
1953                Some(model),
1954                cx,
1955            )
1956        });
1957
1958        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1959        let (event_stream, _receiver) = ToolCallEventStream::test();
1960
1961        let tool = Arc::new(StreamingEditFileTool::new(
1962            project.clone(),
1963            thread.downgrade(),
1964            language_registry,
1965        ));
1966
1967        let task = cx.update(|cx| tool.run(input, event_stream, cx));
1968
1969        // Stream partials for create mode
1970        sender.send_partial(json!({"display_description": "Create new file"}));
1971        cx.run_until_parked();
1972
1973        sender.send_partial(json!({
1974            "display_description": "Create new file",
1975            "path": "root/dir/new_file.txt",
1976            "mode": "create"
1977        }));
1978        cx.run_until_parked();
1979
1980        sender.send_partial(json!({
1981            "display_description": "Create new file",
1982            "path": "root/dir/new_file.txt",
1983            "mode": "create",
1984            "content": "Hello, "
1985        }));
1986        cx.run_until_parked();
1987
1988        // Final with full content
1989        sender.send_final(json!({
1990            "display_description": "Create new file",
1991            "path": "root/dir/new_file.txt",
1992            "mode": "create",
1993            "content": "Hello, World!"
1994        }));
1995
1996        let result = task.await;
1997        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1998            panic!("expected success");
1999        };
2000        assert_eq!(new_text, "Hello, World!");
2001    }
2002
2003    #[gpui::test]
2004    async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) {
2005        init_test(cx);
2006
2007        let fs = project::FakeFs::new(cx.executor());
2008        fs.insert_tree(
2009            "/root",
2010            json!({
2011                "file.txt": "line 1\nline 2\nline 3\n"
2012            }),
2013        )
2014        .await;
2015        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2016        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2017        let context_server_registry =
2018            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2019        let model = Arc::new(FakeLanguageModel::default());
2020        let thread = cx.new(|cx| {
2021            crate::Thread::new(
2022                project.clone(),
2023                cx.new(|_cx| ProjectContext::default()),
2024                context_server_registry,
2025                Templates::new(),
2026                Some(model),
2027                cx,
2028            )
2029        });
2030
2031        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2032        let (event_stream, _receiver) = ToolCallEventStream::test();
2033
2034        let tool = Arc::new(StreamingEditFileTool::new(
2035            project.clone(),
2036            thread.downgrade(),
2037            language_registry,
2038        ));
2039
2040        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2041
2042        // Send final immediately with no partials (simulates non-streaming path)
2043        sender.send_final(json!({
2044            "display_description": "Edit lines",
2045            "path": "root/file.txt",
2046            "mode": "edit",
2047            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
2048        }));
2049
2050        let result = task.await;
2051        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2052            panic!("expected success");
2053        };
2054        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
2055    }
2056
2057    #[gpui::test]
2058    async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) {
2059        init_test(cx);
2060
2061        let fs = project::FakeFs::new(cx.executor());
2062        fs.insert_tree(
2063            "/root",
2064            json!({
2065                "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
2066            }),
2067        )
2068        .await;
2069        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2070        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2071        let context_server_registry =
2072            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2073        let model = Arc::new(FakeLanguageModel::default());
2074        let thread = cx.new(|cx| {
2075            crate::Thread::new(
2076                project.clone(),
2077                cx.new(|_cx| ProjectContext::default()),
2078                context_server_registry,
2079                Templates::new(),
2080                Some(model),
2081                cx,
2082            )
2083        });
2084
2085        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2086        let (event_stream, _receiver) = ToolCallEventStream::test();
2087
2088        let tool = Arc::new(StreamingEditFileTool::new(
2089            project.clone(),
2090            thread.downgrade(),
2091            language_registry,
2092        ));
2093
2094        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2095
2096        // Stream description, path, mode
2097        sender.send_partial(json!({"display_description": "Edit multiple lines"}));
2098        cx.run_until_parked();
2099
2100        sender.send_partial(json!({
2101            "display_description": "Edit multiple lines",
2102            "path": "root/file.txt",
2103            "mode": "edit"
2104        }));
2105        cx.run_until_parked();
2106
2107        // First edit starts streaming (old_text only, still in progress)
2108        sender.send_partial(json!({
2109            "display_description": "Edit multiple lines",
2110            "path": "root/file.txt",
2111            "mode": "edit",
2112            "edits": [{"old_text": "line 1"}]
2113        }));
2114        cx.run_until_parked();
2115
2116        // Buffer should not have changed yet — the first edit is still in progress
2117        // (no second edit has appeared to prove the first is complete)
2118        let buffer_text = project.update(cx, |project, cx| {
2119            let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
2120            project_path.and_then(|pp| {
2121                project
2122                    .get_open_buffer(&pp, cx)
2123                    .map(|buffer| buffer.read(cx).text())
2124            })
2125        });
2126        // Buffer is open (from streaming) but edit 1 is still in-progress
2127        assert_eq!(
2128            buffer_text.as_deref(),
2129            Some("line 1\nline 2\nline 3\nline 4\nline 5\n"),
2130            "Buffer should not be modified while first edit is still in progress"
2131        );
2132
2133        // Second edit appears — this proves the first edit is complete, so it
2134        // should be applied immediately during streaming
2135        sender.send_partial(json!({
2136            "display_description": "Edit multiple lines",
2137            "path": "root/file.txt",
2138            "mode": "edit",
2139            "edits": [
2140                {"old_text": "line 1", "new_text": "MODIFIED 1"},
2141                {"old_text": "line 5"}
2142            ]
2143        }));
2144        cx.run_until_parked();
2145
2146        // First edit should now be applied to the buffer
2147        let buffer_text = project.update(cx, |project, cx| {
2148            let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
2149            project_path.and_then(|pp| {
2150                project
2151                    .get_open_buffer(&pp, cx)
2152                    .map(|buffer| buffer.read(cx).text())
2153            })
2154        });
2155        assert_eq!(
2156            buffer_text.as_deref(),
2157            Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"),
2158            "First edit should be applied during streaming when second edit appears"
2159        );
2160
2161        // Send final complete input
2162        sender.send_final(json!({
2163            "display_description": "Edit multiple lines",
2164            "path": "root/file.txt",
2165            "mode": "edit",
2166            "edits": [
2167                {"old_text": "line 1", "new_text": "MODIFIED 1"},
2168                {"old_text": "line 5", "new_text": "MODIFIED 5"}
2169            ]
2170        }));
2171
2172        let result = task.await;
2173        let StreamingEditFileToolOutput::Success {
2174            new_text, old_text, ..
2175        } = result.unwrap()
2176        else {
2177            panic!("expected success");
2178        };
2179        assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n");
2180        assert_eq!(
2181            *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n",
2182            "old_text should reflect the original file content before any edits"
2183        );
2184    }
2185
2186    #[gpui::test]
2187    async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) {
2188        init_test(cx);
2189
2190        let fs = project::FakeFs::new(cx.executor());
2191        fs.insert_tree(
2192            "/root",
2193            json!({
2194                "file.txt": "aaa\nbbb\nccc\nddd\neee\n"
2195            }),
2196        )
2197        .await;
2198        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2199        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2200        let context_server_registry =
2201            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2202        let model = Arc::new(FakeLanguageModel::default());
2203        let thread = cx.new(|cx| {
2204            crate::Thread::new(
2205                project.clone(),
2206                cx.new(|_cx| ProjectContext::default()),
2207                context_server_registry,
2208                Templates::new(),
2209                Some(model),
2210                cx,
2211            )
2212        });
2213
2214        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2215        let (event_stream, _receiver) = ToolCallEventStream::test();
2216
2217        let tool = Arc::new(StreamingEditFileTool::new(
2218            project.clone(),
2219            thread.downgrade(),
2220            language_registry,
2221        ));
2222
2223        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2224
2225        // Setup: description + path + mode
2226        sender.send_partial(json!({
2227            "display_description": "Edit three lines",
2228            "path": "root/file.txt",
2229            "mode": "edit"
2230        }));
2231        cx.run_until_parked();
2232
2233        // Edit 1 in progress
2234        sender.send_partial(json!({
2235            "display_description": "Edit three lines",
2236            "path": "root/file.txt",
2237            "mode": "edit",
2238            "edits": [{"old_text": "aaa", "new_text": "AAA"}]
2239        }));
2240        cx.run_until_parked();
2241
2242        // Edit 2 appears — edit 1 is now complete and should be applied
2243        sender.send_partial(json!({
2244            "display_description": "Edit three lines",
2245            "path": "root/file.txt",
2246            "mode": "edit",
2247            "edits": [
2248                {"old_text": "aaa", "new_text": "AAA"},
2249                {"old_text": "ccc", "new_text": "CCC"}
2250            ]
2251        }));
2252        cx.run_until_parked();
2253
2254        // Verify edit 1 applied
2255        let buffer_text = project.update(cx, |project, cx| {
2256            let pp = project
2257                .find_project_path(&PathBuf::from("root/file.txt"), cx)
2258                .unwrap();
2259            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2260        });
2261        assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nccc\nddd\neee\n"));
2262
2263        // Edit 3 appears — edit 2 is now complete and should be applied
2264        sender.send_partial(json!({
2265            "display_description": "Edit three lines",
2266            "path": "root/file.txt",
2267            "mode": "edit",
2268            "edits": [
2269                {"old_text": "aaa", "new_text": "AAA"},
2270                {"old_text": "ccc", "new_text": "CCC"},
2271                {"old_text": "eee", "new_text": "EEE"}
2272            ]
2273        }));
2274        cx.run_until_parked();
2275
2276        // Verify edits 1 and 2 both applied
2277        let buffer_text = project.update(cx, |project, cx| {
2278            let pp = project
2279                .find_project_path(&PathBuf::from("root/file.txt"), cx)
2280                .unwrap();
2281            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2282        });
2283        assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\neee\n"));
2284
2285        // Send final
2286        sender.send_final(json!({
2287            "display_description": "Edit three lines",
2288            "path": "root/file.txt",
2289            "mode": "edit",
2290            "edits": [
2291                {"old_text": "aaa", "new_text": "AAA"},
2292                {"old_text": "ccc", "new_text": "CCC"},
2293                {"old_text": "eee", "new_text": "EEE"}
2294            ]
2295        }));
2296
2297        let result = task.await;
2298        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2299            panic!("expected success");
2300        };
2301        assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n");
2302    }
2303
2304    #[gpui::test]
2305    async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) {
2306        init_test(cx);
2307
2308        let fs = project::FakeFs::new(cx.executor());
2309        fs.insert_tree(
2310            "/root",
2311            json!({
2312                "file.txt": "line 1\nline 2\nline 3\n"
2313            }),
2314        )
2315        .await;
2316        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2317        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2318        let context_server_registry =
2319            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2320        let model = Arc::new(FakeLanguageModel::default());
2321        let thread = cx.new(|cx| {
2322            crate::Thread::new(
2323                project.clone(),
2324                cx.new(|_cx| ProjectContext::default()),
2325                context_server_registry,
2326                Templates::new(),
2327                Some(model),
2328                cx,
2329            )
2330        });
2331
2332        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2333        let (event_stream, _receiver) = ToolCallEventStream::test();
2334
2335        let tool = Arc::new(StreamingEditFileTool::new(
2336            project.clone(),
2337            thread.downgrade(),
2338            language_registry,
2339        ));
2340
2341        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2342
2343        // Setup
2344        sender.send_partial(json!({
2345            "display_description": "Edit lines",
2346            "path": "root/file.txt",
2347            "mode": "edit"
2348        }));
2349        cx.run_until_parked();
2350
2351        // Edit 1 (valid) in progress — not yet complete (no second edit)
2352        sender.send_partial(json!({
2353            "display_description": "Edit lines",
2354            "path": "root/file.txt",
2355            "mode": "edit",
2356            "edits": [
2357                {"old_text": "line 1", "new_text": "MODIFIED"}
2358            ]
2359        }));
2360        cx.run_until_parked();
2361
2362        // Edit 2 appears (will fail to match) — this makes edit 1 complete.
2363        // Edit 1 should be applied. Edit 2 is still in-progress (last edit).
2364        sender.send_partial(json!({
2365            "display_description": "Edit lines",
2366            "path": "root/file.txt",
2367            "mode": "edit",
2368            "edits": [
2369                {"old_text": "line 1", "new_text": "MODIFIED"},
2370                {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}
2371            ]
2372        }));
2373        cx.run_until_parked();
2374
2375        // Verify edit 1 was applied
2376        let buffer_text = project.update(cx, |project, cx| {
2377            let pp = project
2378                .find_project_path(&PathBuf::from("root/file.txt"), cx)
2379                .unwrap();
2380            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2381        });
2382        assert_eq!(
2383            buffer_text.as_deref(),
2384            Some("MODIFIED\nline 2\nline 3\n"),
2385            "First edit should be applied even though second edit will fail"
2386        );
2387
2388        // Edit 3 appears — this makes edit 2 "complete", triggering its
2389        // resolution which should fail (old_text doesn't exist in the file).
2390        sender.send_partial(json!({
2391            "display_description": "Edit lines",
2392            "path": "root/file.txt",
2393            "mode": "edit",
2394            "edits": [
2395                {"old_text": "line 1", "new_text": "MODIFIED"},
2396                {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"},
2397                {"old_text": "line 3", "new_text": "MODIFIED 3"}
2398            ]
2399        }));
2400        cx.run_until_parked();
2401
2402        // The error from edit 2 should have propagated out of the partial loop.
2403        // Drop sender to unblock recv() if the loop didn't catch it.
2404        drop(sender);
2405
2406        let result = task.await;
2407        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
2408            panic!("expected error");
2409        };
2410        assert!(
2411            error.contains("Could not find matching text for edit at index 1"),
2412            "Expected error about edit 1 failing, got: {error}"
2413        );
2414    }
2415
2416    #[gpui::test]
2417    async fn test_streaming_overlapping_edits_detected_naturally(cx: &mut TestAppContext) {
2418        init_test(cx);
2419
2420        let fs = project::FakeFs::new(cx.executor());
2421        fs.insert_tree(
2422            "/root",
2423            json!({
2424                "file.txt": "line 1\nline 2\nline 3\n"
2425            }),
2426        )
2427        .await;
2428        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2429        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2430        let context_server_registry =
2431            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2432        let model = Arc::new(FakeLanguageModel::default());
2433        let thread = cx.new(|cx| {
2434            crate::Thread::new(
2435                project.clone(),
2436                cx.new(|_cx| ProjectContext::default()),
2437                context_server_registry,
2438                Templates::new(),
2439                Some(model),
2440                cx,
2441            )
2442        });
2443
2444        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2445        let (event_stream, _receiver) = ToolCallEventStream::test();
2446
2447        let tool = Arc::new(StreamingEditFileTool::new(
2448            project.clone(),
2449            thread.downgrade(),
2450            language_registry,
2451        ));
2452
2453        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2454
2455        // Setup
2456        sender.send_partial(json!({
2457            "display_description": "Overlapping edits",
2458            "path": "root/file.txt",
2459            "mode": "edit"
2460        }));
2461        cx.run_until_parked();
2462
2463        // Edit 1 targets "line 1\nline 2" and replaces it.
2464        // Edit 2 targets "line 2\nline 3" — but after edit 1 is applied,
2465        // "line 2" has been removed so this should fail to match.
2466        // Edit 3 exists to make edit 2 "complete" during streaming.
2467        sender.send_partial(json!({
2468            "display_description": "Overlapping edits",
2469            "path": "root/file.txt",
2470            "mode": "edit",
2471            "edits": [
2472                {"old_text": "line 1\nline 2", "new_text": "REPLACED"},
2473                {"old_text": "line 2\nline 3", "new_text": "ALSO REPLACED"},
2474                {"old_text": "line 3", "new_text": "DUMMY"}
2475            ]
2476        }));
2477        cx.run_until_parked();
2478
2479        // Edit 1 was applied, edit 2 should fail since "line 2" no longer exists
2480        drop(sender);
2481
2482        let result = task.await;
2483        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
2484            panic!("expected error");
2485        };
2486        assert!(
2487            error.contains("Could not find matching text for edit at index 1"),
2488            "Expected overlapping edit to fail naturally, got: {error}"
2489        );
2490    }
2491
2492    #[gpui::test]
2493    async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {
2494        init_test(cx);
2495
2496        let fs = project::FakeFs::new(cx.executor());
2497        fs.insert_tree(
2498            "/root",
2499            json!({
2500                "file.txt": "hello world\n"
2501            }),
2502        )
2503        .await;
2504        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2505        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2506        let context_server_registry =
2507            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2508        let model = Arc::new(FakeLanguageModel::default());
2509        let thread = cx.new(|cx| {
2510            crate::Thread::new(
2511                project.clone(),
2512                cx.new(|_cx| ProjectContext::default()),
2513                context_server_registry,
2514                Templates::new(),
2515                Some(model),
2516                cx,
2517            )
2518        });
2519
2520        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2521        let (event_stream, _receiver) = ToolCallEventStream::test();
2522
2523        let tool = Arc::new(StreamingEditFileTool::new(
2524            project.clone(),
2525            thread.downgrade(),
2526            language_registry,
2527        ));
2528
2529        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2530
2531        // Setup + single edit that stays in-progress (no second edit to prove completion)
2532        sender.send_partial(json!({
2533            "display_description": "Single edit",
2534            "path": "root/file.txt",
2535            "mode": "edit",
2536            "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
2537        }));
2538        cx.run_until_parked();
2539
2540        // Buffer should NOT be modified — the single edit is still in-progress
2541        let buffer_text = project.update(cx, |project, cx| {
2542            let pp = project
2543                .find_project_path(&PathBuf::from("root/file.txt"), cx)
2544                .unwrap();
2545            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2546        });
2547        assert_eq!(
2548            buffer_text.as_deref(),
2549            Some("hello world\n"),
2550            "Single in-progress edit should not be applied during streaming"
2551        );
2552
2553        // Send final — the edit is applied during finalization
2554        sender.send_final(json!({
2555            "display_description": "Single edit",
2556            "path": "root/file.txt",
2557            "mode": "edit",
2558            "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
2559        }));
2560
2561        let result = task.await;
2562        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2563            panic!("expected success");
2564        };
2565        assert_eq!(new_text, "goodbye world\n");
2566    }
2567
2568    #[gpui::test]
2569    async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) {
2570        init_test(cx);
2571
2572        let fs = project::FakeFs::new(cx.executor());
2573        fs.insert_tree(
2574            "/root",
2575            json!({
2576                "file.txt": "line 1\nline 2\nline 3\n"
2577            }),
2578        )
2579        .await;
2580        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2581        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2582        let context_server_registry =
2583            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2584        let model = Arc::new(FakeLanguageModel::default());
2585        let thread = cx.new(|cx| {
2586            crate::Thread::new(
2587                project.clone(),
2588                cx.new(|_cx| ProjectContext::default()),
2589                context_server_registry,
2590                Templates::new(),
2591                Some(model),
2592                cx,
2593            )
2594        });
2595
2596        let (sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2597            ToolInput::test();
2598
2599        let (event_stream, _event_rx) = ToolCallEventStream::test();
2600        let task = cx.update(|cx| {
2601            Arc::new(StreamingEditFileTool::new(
2602                project.clone(),
2603                thread.downgrade(),
2604                language_registry,
2605            ))
2606            .run(input, event_stream, cx)
2607        });
2608
2609        // Send progressively more complete partial snapshots, as the LLM would
2610        sender.send_partial(json!({
2611            "display_description": "Edit lines"
2612        }));
2613        cx.run_until_parked();
2614
2615        sender.send_partial(json!({
2616            "display_description": "Edit lines",
2617            "path": "root/file.txt",
2618            "mode": "edit"
2619        }));
2620        cx.run_until_parked();
2621
2622        sender.send_partial(json!({
2623            "display_description": "Edit lines",
2624            "path": "root/file.txt",
2625            "mode": "edit",
2626            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
2627        }));
2628        cx.run_until_parked();
2629
2630        // Send the final complete input
2631        sender.send_final(json!({
2632            "display_description": "Edit lines",
2633            "path": "root/file.txt",
2634            "mode": "edit",
2635            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
2636        }));
2637
2638        let result = task.await;
2639        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2640            panic!("expected success");
2641        };
2642        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
2643    }
2644
2645    #[gpui::test]
2646    async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) {
2647        init_test(cx);
2648
2649        let fs = project::FakeFs::new(cx.executor());
2650        fs.insert_tree(
2651            "/root",
2652            json!({
2653                "file.txt": "hello world\n"
2654            }),
2655        )
2656        .await;
2657        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2658        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2659        let context_server_registry =
2660            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2661        let model = Arc::new(FakeLanguageModel::default());
2662        let thread = cx.new(|cx| {
2663            crate::Thread::new(
2664                project.clone(),
2665                cx.new(|_cx| ProjectContext::default()),
2666                context_server_registry,
2667                Templates::new(),
2668                Some(model),
2669                cx,
2670            )
2671        });
2672
2673        let (sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2674            ToolInput::test();
2675
2676        let (event_stream, _event_rx) = ToolCallEventStream::test();
2677        let task = cx.update(|cx| {
2678            Arc::new(StreamingEditFileTool::new(
2679                project.clone(),
2680                thread.downgrade(),
2681                language_registry,
2682            ))
2683            .run(input, event_stream, cx)
2684        });
2685
2686        // Send a partial then drop the sender without sending final
2687        sender.send_partial(json!({
2688            "display_description": "Edit file"
2689        }));
2690        cx.run_until_parked();
2691
2692        drop(sender);
2693
2694        let result = task.await;
2695        assert!(
2696            result.is_err(),
2697            "Tool should error when sender is dropped without sending final input"
2698        );
2699    }
2700
2701    #[gpui::test]
2702    async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) {
2703        init_test(cx);
2704
2705        let fs = project::FakeFs::new(cx.executor());
2706        fs.insert_tree("/root", json!({"dir": {}})).await;
2707        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2708        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2709        let context_server_registry =
2710            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2711        let model = Arc::new(FakeLanguageModel::default());
2712        let thread = cx.new(|cx| {
2713            crate::Thread::new(
2714                project.clone(),
2715                cx.new(|_cx| ProjectContext::default()),
2716                context_server_registry,
2717                Templates::new(),
2718                Some(model),
2719                cx,
2720            )
2721        });
2722
2723        // Create a channel and send multiple partials before a final, then use
2724        // ToolInput::resolved-style immediate delivery to confirm recv() works
2725        // when partials are already buffered.
2726        let (sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2727            ToolInput::test();
2728
2729        let (event_stream, _event_rx) = ToolCallEventStream::test();
2730        let task = cx.update(|cx| {
2731            Arc::new(StreamingEditFileTool::new(
2732                project.clone(),
2733                thread.downgrade(),
2734                language_registry,
2735            ))
2736            .run(input, event_stream, cx)
2737        });
2738
2739        // Buffer several partials before sending the final
2740        sender.send_partial(json!({"display_description": "Create"}));
2741        sender.send_partial(json!({"display_description": "Create", "path": "root/dir/new.txt"}));
2742        sender.send_partial(json!({
2743            "display_description": "Create",
2744            "path": "root/dir/new.txt",
2745            "mode": "create"
2746        }));
2747        sender.send_final(json!({
2748            "display_description": "Create",
2749            "path": "root/dir/new.txt",
2750            "mode": "create",
2751            "content": "streamed content"
2752        }));
2753
2754        let result = task.await;
2755        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2756            panic!("expected success");
2757        };
2758        assert_eq!(new_text, "streamed content");
2759    }
2760
2761    #[gpui::test]
2762    async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {
2763        let mode = StreamingEditFileMode::Create;
2764
2765        let result = test_resolve_path(&mode, "root/new.txt", cx);
2766        assert_resolved_path_eq(result.await, rel_path("new.txt"));
2767
2768        let result = test_resolve_path(&mode, "new.txt", cx);
2769        assert_resolved_path_eq(result.await, rel_path("new.txt"));
2770
2771        let result = test_resolve_path(&mode, "dir/new.txt", cx);
2772        assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
2773
2774        let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx);
2775        assert_eq!(
2776            result.await.unwrap_err().to_string(),
2777            "Can't create file: file already exists"
2778        );
2779
2780        let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);
2781        assert_eq!(
2782            result.await.unwrap_err().to_string(),
2783            "Can't create file: parent directory doesn't exist"
2784        );
2785    }
2786
2787    #[gpui::test]
2788    async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) {
2789        let mode = StreamingEditFileMode::Edit;
2790
2791        let path_with_root = "root/dir/subdir/existing.txt";
2792        let path_without_root = "dir/subdir/existing.txt";
2793        let result = test_resolve_path(&mode, path_with_root, cx);
2794        assert_resolved_path_eq(result.await, rel_path(path_without_root));
2795
2796        let result = test_resolve_path(&mode, path_without_root, cx);
2797        assert_resolved_path_eq(result.await, rel_path(path_without_root));
2798
2799        let result = test_resolve_path(&mode, "root/nonexistent.txt", cx);
2800        assert_eq!(
2801            result.await.unwrap_err().to_string(),
2802            "Can't edit file: path not found"
2803        );
2804
2805        let result = test_resolve_path(&mode, "root/dir", cx);
2806        assert_eq!(
2807            result.await.unwrap_err().to_string(),
2808            "Can't edit file: path is a directory"
2809        );
2810    }
2811
2812    async fn test_resolve_path(
2813        mode: &StreamingEditFileMode,
2814        path: &str,
2815        cx: &mut TestAppContext,
2816    ) -> anyhow::Result<ProjectPath> {
2817        init_test(cx);
2818
2819        let fs = project::FakeFs::new(cx.executor());
2820        fs.insert_tree(
2821            "/root",
2822            json!({
2823                "dir": {
2824                    "subdir": {
2825                        "existing.txt": "hello"
2826                    }
2827                }
2828            }),
2829        )
2830        .await;
2831        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2832
2833        cx.update(|cx| resolve_path(mode.clone(), &PathBuf::from(path), &project, cx))
2834    }
2835
2836    #[track_caller]
2837    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
2838        let actual = path.expect("Should return valid path").path;
2839        assert_eq!(actual.as_ref(), expected);
2840    }
2841
2842    #[gpui::test]
2843    async fn test_streaming_format_on_save(cx: &mut TestAppContext) {
2844        init_test(cx);
2845
2846        let fs = project::FakeFs::new(cx.executor());
2847        fs.insert_tree("/root", json!({"src": {}})).await;
2848
2849        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2850
2851        let rust_language = Arc::new(language::Language::new(
2852            language::LanguageConfig {
2853                name: "Rust".into(),
2854                matcher: language::LanguageMatcher {
2855                    path_suffixes: vec!["rs".to_string()],
2856                    ..Default::default()
2857                },
2858                ..Default::default()
2859            },
2860            None,
2861        ));
2862
2863        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
2864        language_registry.add(rust_language);
2865
2866        let mut fake_language_servers = language_registry.register_fake_lsp(
2867            "Rust",
2868            language::FakeLspAdapter {
2869                capabilities: lsp::ServerCapabilities {
2870                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
2871                    ..Default::default()
2872                },
2873                ..Default::default()
2874            },
2875        );
2876
2877        fs.save(
2878            path!("/root/src/main.rs").as_ref(),
2879            &"initial content".into(),
2880            language::LineEnding::Unix,
2881        )
2882        .await
2883        .unwrap();
2884
2885        // Open the buffer to trigger LSP initialization
2886        let buffer = project
2887            .update(cx, |project, cx| {
2888                project.open_local_buffer(path!("/root/src/main.rs"), cx)
2889            })
2890            .await
2891            .unwrap();
2892
2893        // Register the buffer with language servers
2894        let _handle = project.update(cx, |project, cx| {
2895            project.register_buffer_with_language_servers(&buffer, cx)
2896        });
2897
2898        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
2899        const FORMATTED_CONTENT: &str =
2900            "This file was formatted by the fake formatter in the test.\n";
2901
2902        // Get the fake language server and set up formatting handler
2903        let fake_language_server = fake_language_servers.next().await.unwrap();
2904        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
2905            |_, _| async move {
2906                Ok(Some(vec![lsp::TextEdit {
2907                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
2908                    new_text: FORMATTED_CONTENT.to_string(),
2909                }]))
2910            }
2911        });
2912
2913        let context_server_registry =
2914            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2915        let model = Arc::new(FakeLanguageModel::default());
2916        let thread = cx.new(|cx| {
2917            crate::Thread::new(
2918                project.clone(),
2919                cx.new(|_cx| ProjectContext::default()),
2920                context_server_registry,
2921                Templates::new(),
2922                Some(model.clone()),
2923                cx,
2924            )
2925        });
2926
2927        // Test with format_on_save enabled
2928        cx.update(|cx| {
2929            SettingsStore::update_global(cx, |store, cx| {
2930                store.update_user_settings(cx, |settings| {
2931                    settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
2932                    settings.project.all_languages.defaults.formatter =
2933                        Some(language::language_settings::FormatterList::default());
2934                });
2935            });
2936        });
2937
2938        // Use streaming pattern so executor can pump the LSP request/response
2939        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2940        let (event_stream, _receiver) = ToolCallEventStream::test();
2941
2942        let tool = Arc::new(StreamingEditFileTool::new(
2943            project.clone(),
2944            thread.downgrade(),
2945            language_registry.clone(),
2946        ));
2947
2948        let task = cx.update(|cx| tool.run(input, event_stream, cx));
2949
2950        sender.send_partial(json!({
2951            "display_description": "Create main function",
2952            "path": "root/src/main.rs",
2953            "mode": "overwrite"
2954        }));
2955        cx.run_until_parked();
2956
2957        sender.send_final(json!({
2958            "display_description": "Create main function",
2959            "path": "root/src/main.rs",
2960            "mode": "overwrite",
2961            "content": UNFORMATTED_CONTENT
2962        }));
2963
2964        let result = task.await;
2965        assert!(result.is_ok());
2966
2967        cx.executor().run_until_parked();
2968
2969        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
2970        assert_eq!(
2971            new_content.replace("\r\n", "\n"),
2972            FORMATTED_CONTENT,
2973            "Code should be formatted when format_on_save is enabled"
2974        );
2975
2976        let stale_buffer_count = thread
2977            .read_with(cx, |thread, _cx| thread.action_log.clone())
2978            .read_with(cx, |log, cx| log.stale_buffers(cx).count());
2979
2980        assert_eq!(
2981            stale_buffer_count, 0,
2982            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.",
2983            stale_buffer_count
2984        );
2985
2986        // Test with format_on_save disabled
2987        cx.update(|cx| {
2988            SettingsStore::update_global(cx, |store, cx| {
2989                store.update_user_settings(cx, |settings| {
2990                    settings.project.all_languages.defaults.format_on_save =
2991                        Some(FormatOnSave::Off);
2992                });
2993            });
2994        });
2995
2996        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2997        let (event_stream, _receiver) = ToolCallEventStream::test();
2998
2999        let tool = Arc::new(StreamingEditFileTool::new(
3000            project.clone(),
3001            thread.downgrade(),
3002            language_registry,
3003        ));
3004
3005        let task = cx.update(|cx| tool.run(input, event_stream, cx));
3006
3007        sender.send_partial(json!({
3008            "display_description": "Update main function",
3009            "path": "root/src/main.rs",
3010            "mode": "overwrite"
3011        }));
3012        cx.run_until_parked();
3013
3014        sender.send_final(json!({
3015            "display_description": "Update main function",
3016            "path": "root/src/main.rs",
3017            "mode": "overwrite",
3018            "content": UNFORMATTED_CONTENT
3019        }));
3020
3021        let result = task.await;
3022        assert!(result.is_ok());
3023
3024        cx.executor().run_until_parked();
3025
3026        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
3027        assert_eq!(
3028            new_content.replace("\r\n", "\n"),
3029            UNFORMATTED_CONTENT,
3030            "Code should not be formatted when format_on_save is disabled"
3031        );
3032    }
3033
3034    #[gpui::test]
3035    async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) {
3036        init_test(cx);
3037
3038        let fs = project::FakeFs::new(cx.executor());
3039        fs.insert_tree("/root", json!({"src": {}})).await;
3040
3041        fs.save(
3042            path!("/root/src/main.rs").as_ref(),
3043            &"initial content".into(),
3044            language::LineEnding::Unix,
3045        )
3046        .await
3047        .unwrap();
3048
3049        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3050        let context_server_registry =
3051            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3052        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3053        let model = Arc::new(FakeLanguageModel::default());
3054        let thread = cx.new(|cx| {
3055            crate::Thread::new(
3056                project.clone(),
3057                cx.new(|_cx| ProjectContext::default()),
3058                context_server_registry,
3059                Templates::new(),
3060                Some(model.clone()),
3061                cx,
3062            )
3063        });
3064
3065        // Test with remove_trailing_whitespace_on_save enabled
3066        cx.update(|cx| {
3067            SettingsStore::update_global(cx, |store, cx| {
3068                store.update_user_settings(cx, |settings| {
3069                    settings
3070                        .project
3071                        .all_languages
3072                        .defaults
3073                        .remove_trailing_whitespace_on_save = Some(true);
3074                });
3075            });
3076        });
3077
3078        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
3079            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
3080
3081        let result = cx
3082            .update(|cx| {
3083                let input = StreamingEditFileToolInput {
3084                    display_description: "Create main function".into(),
3085                    path: "root/src/main.rs".into(),
3086                    mode: StreamingEditFileMode::Overwrite,
3087                    content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
3088                    edits: None,
3089                };
3090                Arc::new(StreamingEditFileTool::new(
3091                    project.clone(),
3092                    thread.downgrade(),
3093                    language_registry.clone(),
3094                ))
3095                .run(
3096                    ToolInput::resolved(input),
3097                    ToolCallEventStream::test().0,
3098                    cx,
3099                )
3100            })
3101            .await;
3102        assert!(result.is_ok());
3103
3104        cx.executor().run_until_parked();
3105
3106        assert_eq!(
3107            fs.load(path!("/root/src/main.rs").as_ref())
3108                .await
3109                .unwrap()
3110                .replace("\r\n", "\n"),
3111            "fn main() {\n    println!(\"Hello!\");\n}\n",
3112            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
3113        );
3114
3115        // Test with remove_trailing_whitespace_on_save disabled
3116        cx.update(|cx| {
3117            SettingsStore::update_global(cx, |store, cx| {
3118                store.update_user_settings(cx, |settings| {
3119                    settings
3120                        .project
3121                        .all_languages
3122                        .defaults
3123                        .remove_trailing_whitespace_on_save = Some(false);
3124                });
3125            });
3126        });
3127
3128        let result = cx
3129            .update(|cx| {
3130                let input = StreamingEditFileToolInput {
3131                    display_description: "Update main function".into(),
3132                    path: "root/src/main.rs".into(),
3133                    mode: StreamingEditFileMode::Overwrite,
3134                    content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
3135                    edits: None,
3136                };
3137                Arc::new(StreamingEditFileTool::new(
3138                    project.clone(),
3139                    thread.downgrade(),
3140                    language_registry,
3141                ))
3142                .run(
3143                    ToolInput::resolved(input),
3144                    ToolCallEventStream::test().0,
3145                    cx,
3146                )
3147            })
3148            .await;
3149        assert!(result.is_ok());
3150
3151        cx.executor().run_until_parked();
3152
3153        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
3154        assert_eq!(
3155            final_content.replace("\r\n", "\n"),
3156            CONTENT_WITH_TRAILING_WHITESPACE,
3157            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
3158        );
3159    }
3160
3161    #[gpui::test]
3162    async fn test_streaming_authorize(cx: &mut TestAppContext) {
3163        init_test(cx);
3164        let fs = project::FakeFs::new(cx.executor());
3165        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3166        let context_server_registry =
3167            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3168        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3169        let model = Arc::new(FakeLanguageModel::default());
3170        let thread = cx.new(|cx| {
3171            crate::Thread::new(
3172                project.clone(),
3173                cx.new(|_cx| ProjectContext::default()),
3174                context_server_registry,
3175                Templates::new(),
3176                Some(model.clone()),
3177                cx,
3178            )
3179        });
3180        let tool = Arc::new(StreamingEditFileTool::new(
3181            project.clone(),
3182            thread.downgrade(),
3183            language_registry,
3184        ));
3185        fs.insert_tree("/root", json!({})).await;
3186
3187        // Test 1: Path with .zed component should require confirmation
3188        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3189        let _auth = cx.update(|cx| {
3190            tool.authorize(
3191                &PathBuf::from(".zed/settings.json"),
3192                "test 1",
3193                &stream_tx,
3194                cx,
3195            )
3196        });
3197
3198        let event = stream_rx.expect_authorization().await;
3199        assert_eq!(
3200            event.tool_call.fields.title,
3201            Some("test 1 (local settings)".into())
3202        );
3203
3204        // Test 2: Path outside project should require confirmation
3205        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3206        let _auth =
3207            cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 2", &stream_tx, cx));
3208
3209        let event = stream_rx.expect_authorization().await;
3210        assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
3211
3212        // Test 3: Relative path without .zed should not require confirmation
3213        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3214        cx.update(|cx| {
3215            tool.authorize(&PathBuf::from("root/src/main.rs"), "test 3", &stream_tx, cx)
3216        })
3217        .await
3218        .unwrap();
3219        assert!(stream_rx.try_next().is_err());
3220
3221        // Test 4: Path with .zed in the middle should require confirmation
3222        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3223        let _auth = cx.update(|cx| {
3224            tool.authorize(
3225                &PathBuf::from("root/.zed/tasks.json"),
3226                "test 4",
3227                &stream_tx,
3228                cx,
3229            )
3230        });
3231        let event = stream_rx.expect_authorization().await;
3232        assert_eq!(
3233            event.tool_call.fields.title,
3234            Some("test 4 (local settings)".into())
3235        );
3236
3237        // Test 5: When global default is allow, sensitive and outside-project
3238        // paths still require confirmation
3239        cx.update(|cx| {
3240            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3241            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3242            agent_settings::AgentSettings::override_global(settings, cx);
3243        });
3244
3245        // 5.1: .zed/settings.json is a sensitive path — still prompts
3246        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3247        let _auth = cx.update(|cx| {
3248            tool.authorize(
3249                &PathBuf::from(".zed/settings.json"),
3250                "test 5.1",
3251                &stream_tx,
3252                cx,
3253            )
3254        });
3255        let event = stream_rx.expect_authorization().await;
3256        assert_eq!(
3257            event.tool_call.fields.title,
3258            Some("test 5.1 (local settings)".into())
3259        );
3260
3261        // 5.2: /etc/hosts is outside the project, but Allow auto-approves
3262        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3263        cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx))
3264            .await
3265            .unwrap();
3266        assert!(stream_rx.try_next().is_err());
3267
3268        // 5.3: Normal in-project path with allow — no confirmation needed
3269        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3270        cx.update(|cx| {
3271            tool.authorize(
3272                &PathBuf::from("root/src/main.rs"),
3273                "test 5.3",
3274                &stream_tx,
3275                cx,
3276            )
3277        })
3278        .await
3279        .unwrap();
3280        assert!(stream_rx.try_next().is_err());
3281
3282        // 5.4: With Confirm default, non-project paths still prompt
3283        cx.update(|cx| {
3284            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3285            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
3286            agent_settings::AgentSettings::override_global(settings, cx);
3287        });
3288
3289        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3290        let _auth = cx
3291            .update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.4", &stream_tx, cx));
3292
3293        let event = stream_rx.expect_authorization().await;
3294        assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
3295    }
3296
3297    #[gpui::test]
3298    async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) {
3299        init_test(cx);
3300
3301        let fs = project::FakeFs::new(cx.executor());
3302        fs.insert_tree("/root", json!({})).await;
3303        fs.insert_tree("/outside", json!({})).await;
3304        fs.insert_symlink("/root/link", PathBuf::from("/outside"))
3305            .await;
3306
3307        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3308        let context_server_registry =
3309            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3310        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3311        let model = Arc::new(FakeLanguageModel::default());
3312        let thread = cx.new(|cx| {
3313            crate::Thread::new(
3314                project.clone(),
3315                cx.new(|_cx| ProjectContext::default()),
3316                context_server_registry,
3317                Templates::new(),
3318                Some(model),
3319                cx,
3320            )
3321        });
3322        let tool = Arc::new(StreamingEditFileTool::new(
3323            project,
3324            thread.downgrade(),
3325            language_registry,
3326        ));
3327
3328        cx.update(|cx| {
3329            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3330            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3331            agent_settings::AgentSettings::override_global(settings, cx);
3332        });
3333
3334        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3335        let authorize_task = cx.update(|cx| {
3336            tool.authorize(
3337                &PathBuf::from("link/new.txt"),
3338                "create through symlink",
3339                &stream_tx,
3340                cx,
3341            )
3342        });
3343
3344        let event = stream_rx.expect_authorization().await;
3345        assert!(
3346            event
3347                .tool_call
3348                .fields
3349                .title
3350                .as_deref()
3351                .is_some_and(|title| title.contains("points outside the project")),
3352            "Expected symlink escape authorization for create under external symlink"
3353        );
3354
3355        event
3356            .response
3357            .send(acp::PermissionOptionId::new("allow"))
3358            .unwrap();
3359        authorize_task.await.unwrap();
3360    }
3361
3362    #[gpui::test]
3363    async fn test_streaming_edit_file_symlink_escape_requests_authorization(
3364        cx: &mut TestAppContext,
3365    ) {
3366        init_test(cx);
3367
3368        let fs = project::FakeFs::new(cx.executor());
3369        fs.insert_tree(
3370            path!("/root"),
3371            json!({
3372                "src": { "main.rs": "fn main() {}" }
3373            }),
3374        )
3375        .await;
3376        fs.insert_tree(
3377            path!("/outside"),
3378            json!({
3379                "config.txt": "old content"
3380            }),
3381        )
3382        .await;
3383        fs.create_symlink(
3384            path!("/root/link_to_external").as_ref(),
3385            PathBuf::from("/outside"),
3386        )
3387        .await
3388        .unwrap();
3389
3390        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3391        cx.executor().run_until_parked();
3392
3393        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
3394        let context_server_registry =
3395            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3396        let model = Arc::new(FakeLanguageModel::default());
3397        let thread = cx.new(|cx| {
3398            crate::Thread::new(
3399                project.clone(),
3400                cx.new(|_cx| ProjectContext::default()),
3401                context_server_registry,
3402                Templates::new(),
3403                Some(model),
3404                cx,
3405            )
3406        });
3407        let tool = Arc::new(StreamingEditFileTool::new(
3408            project.clone(),
3409            thread.downgrade(),
3410            language_registry,
3411        ));
3412
3413        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3414        let _authorize_task = cx.update(|cx| {
3415            tool.authorize(
3416                &PathBuf::from("link_to_external/config.txt"),
3417                "edit through symlink",
3418                &stream_tx,
3419                cx,
3420            )
3421        });
3422
3423        let auth = stream_rx.expect_authorization().await;
3424        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
3425        assert!(
3426            title.contains("points outside the project"),
3427            "title should mention symlink escape, got: {title}"
3428        );
3429    }
3430
3431    #[gpui::test]
3432    async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
3433        init_test(cx);
3434
3435        let fs = project::FakeFs::new(cx.executor());
3436        fs.insert_tree(
3437            path!("/root"),
3438            json!({
3439                "src": { "main.rs": "fn main() {}" }
3440            }),
3441        )
3442        .await;
3443        fs.insert_tree(
3444            path!("/outside"),
3445            json!({
3446                "config.txt": "old content"
3447            }),
3448        )
3449        .await;
3450        fs.create_symlink(
3451            path!("/root/link_to_external").as_ref(),
3452            PathBuf::from("/outside"),
3453        )
3454        .await
3455        .unwrap();
3456
3457        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3458        cx.executor().run_until_parked();
3459
3460        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
3461        let context_server_registry =
3462            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3463        let model = Arc::new(FakeLanguageModel::default());
3464        let thread = cx.new(|cx| {
3465            crate::Thread::new(
3466                project.clone(),
3467                cx.new(|_cx| ProjectContext::default()),
3468                context_server_registry,
3469                Templates::new(),
3470                Some(model),
3471                cx,
3472            )
3473        });
3474        let tool = Arc::new(StreamingEditFileTool::new(
3475            project.clone(),
3476            thread.downgrade(),
3477            language_registry,
3478        ));
3479
3480        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3481        let authorize_task = cx.update(|cx| {
3482            tool.authorize(
3483                &PathBuf::from("link_to_external/config.txt"),
3484                "edit through symlink",
3485                &stream_tx,
3486                cx,
3487            )
3488        });
3489
3490        let auth = stream_rx.expect_authorization().await;
3491        drop(auth); // deny by dropping
3492
3493        let result = authorize_task.await;
3494        assert!(result.is_err(), "should fail when denied");
3495    }
3496
3497    #[gpui::test]
3498    async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
3499        init_test(cx);
3500        cx.update(|cx| {
3501            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3502            settings.tool_permissions.tools.insert(
3503                "edit_file".into(),
3504                agent_settings::ToolRules {
3505                    default: Some(settings::ToolPermissionMode::Deny),
3506                    ..Default::default()
3507                },
3508            );
3509            agent_settings::AgentSettings::override_global(settings, cx);
3510        });
3511
3512        let fs = project::FakeFs::new(cx.executor());
3513        fs.insert_tree(
3514            path!("/root"),
3515            json!({
3516                "src": { "main.rs": "fn main() {}" }
3517            }),
3518        )
3519        .await;
3520        fs.insert_tree(
3521            path!("/outside"),
3522            json!({
3523                "config.txt": "old content"
3524            }),
3525        )
3526        .await;
3527        fs.create_symlink(
3528            path!("/root/link_to_external").as_ref(),
3529            PathBuf::from("/outside"),
3530        )
3531        .await
3532        .unwrap();
3533
3534        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3535        cx.executor().run_until_parked();
3536
3537        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
3538        let context_server_registry =
3539            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3540        let model = Arc::new(FakeLanguageModel::default());
3541        let thread = cx.new(|cx| {
3542            crate::Thread::new(
3543                project.clone(),
3544                cx.new(|_cx| ProjectContext::default()),
3545                context_server_registry,
3546                Templates::new(),
3547                Some(model),
3548                cx,
3549            )
3550        });
3551        let tool = Arc::new(StreamingEditFileTool::new(
3552            project.clone(),
3553            thread.downgrade(),
3554            language_registry,
3555        ));
3556
3557        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3558        let result = cx
3559            .update(|cx| {
3560                tool.authorize(
3561                    &PathBuf::from("link_to_external/config.txt"),
3562                    "edit through symlink",
3563                    &stream_tx,
3564                    cx,
3565                )
3566            })
3567            .await;
3568
3569        assert!(result.is_err(), "Tool should fail when policy denies");
3570        assert!(
3571            !matches!(
3572                stream_rx.try_next(),
3573                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
3574            ),
3575            "Deny policy should not emit symlink authorization prompt",
3576        );
3577    }
3578
3579    #[gpui::test]
3580    async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) {
3581        init_test(cx);
3582        let fs = project::FakeFs::new(cx.executor());
3583        fs.insert_tree("/project", json!({})).await;
3584        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
3585        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3586        let context_server_registry =
3587            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3588        let model = Arc::new(FakeLanguageModel::default());
3589        let thread = cx.new(|cx| {
3590            crate::Thread::new(
3591                project.clone(),
3592                cx.new(|_cx| ProjectContext::default()),
3593                context_server_registry,
3594                Templates::new(),
3595                Some(model.clone()),
3596                cx,
3597            )
3598        });
3599        let tool = Arc::new(StreamingEditFileTool::new(
3600            project.clone(),
3601            thread.downgrade(),
3602            language_registry,
3603        ));
3604
3605        let test_cases = vec![
3606            (
3607                "/etc/hosts",
3608                true,
3609                "System file should require confirmation",
3610            ),
3611            (
3612                "/usr/local/bin/script",
3613                true,
3614                "System bin file should require confirmation",
3615            ),
3616            (
3617                "project/normal_file.rs",
3618                false,
3619                "Normal project file should not require confirmation",
3620            ),
3621        ];
3622
3623        for (path, should_confirm, description) in test_cases {
3624            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3625            let auth =
3626                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
3627
3628            if should_confirm {
3629                stream_rx.expect_authorization().await;
3630            } else {
3631                auth.await.unwrap();
3632                assert!(
3633                    stream_rx.try_next().is_err(),
3634                    "Failed for case: {} - path: {} - expected no confirmation but got one",
3635                    description,
3636                    path
3637                );
3638            }
3639        }
3640    }
3641
3642    #[gpui::test]
3643    async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
3644        init_test(cx);
3645        let fs = project::FakeFs::new(cx.executor());
3646
3647        fs.insert_tree(
3648            "/workspace/frontend",
3649            json!({
3650                "src": {
3651                    "main.js": "console.log('frontend');"
3652                }
3653            }),
3654        )
3655        .await;
3656        fs.insert_tree(
3657            "/workspace/backend",
3658            json!({
3659                "src": {
3660                    "main.rs": "fn main() {}"
3661                }
3662            }),
3663        )
3664        .await;
3665        fs.insert_tree(
3666            "/workspace/shared",
3667            json!({
3668                ".zed": {
3669                    "settings.json": "{}"
3670                }
3671            }),
3672        )
3673        .await;
3674
3675        let project = Project::test(
3676            fs.clone(),
3677            [
3678                path!("/workspace/frontend").as_ref(),
3679                path!("/workspace/backend").as_ref(),
3680                path!("/workspace/shared").as_ref(),
3681            ],
3682            cx,
3683        )
3684        .await;
3685        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3686        let context_server_registry =
3687            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3688        let model = Arc::new(FakeLanguageModel::default());
3689        let thread = cx.new(|cx| {
3690            crate::Thread::new(
3691                project.clone(),
3692                cx.new(|_cx| ProjectContext::default()),
3693                context_server_registry.clone(),
3694                Templates::new(),
3695                Some(model.clone()),
3696                cx,
3697            )
3698        });
3699        let tool = Arc::new(StreamingEditFileTool::new(
3700            project.clone(),
3701            thread.downgrade(),
3702            language_registry,
3703        ));
3704
3705        let test_cases = vec![
3706            ("frontend/src/main.js", false, "File in first worktree"),
3707            ("backend/src/main.rs", false, "File in second worktree"),
3708            (
3709                "shared/.zed/settings.json",
3710                true,
3711                ".zed file in third worktree",
3712            ),
3713            ("/etc/hosts", true, "Absolute path outside all worktrees"),
3714            (
3715                "../outside/file.txt",
3716                true,
3717                "Relative path outside worktrees",
3718            ),
3719        ];
3720
3721        for (path, should_confirm, description) in test_cases {
3722            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3723            let auth =
3724                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
3725
3726            if should_confirm {
3727                stream_rx.expect_authorization().await;
3728            } else {
3729                auth.await.unwrap();
3730                assert!(
3731                    stream_rx.try_next().is_err(),
3732                    "Failed for case: {} - path: {} - expected no confirmation but got one",
3733                    description,
3734                    path
3735                );
3736            }
3737        }
3738    }
3739
3740    #[gpui::test]
3741    async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
3742        init_test(cx);
3743        let fs = project::FakeFs::new(cx.executor());
3744        fs.insert_tree(
3745            "/project",
3746            json!({
3747                ".zed": {
3748                    "settings.json": "{}"
3749                },
3750                "src": {
3751                    ".zed": {
3752                        "local.json": "{}"
3753                    }
3754                }
3755            }),
3756        )
3757        .await;
3758        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
3759        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3760        let context_server_registry =
3761            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3762        let model = Arc::new(FakeLanguageModel::default());
3763        let thread = cx.new(|cx| {
3764            crate::Thread::new(
3765                project.clone(),
3766                cx.new(|_cx| ProjectContext::default()),
3767                context_server_registry.clone(),
3768                Templates::new(),
3769                Some(model.clone()),
3770                cx,
3771            )
3772        });
3773        let tool = Arc::new(StreamingEditFileTool::new(
3774            project.clone(),
3775            thread.downgrade(),
3776            language_registry,
3777        ));
3778
3779        let test_cases = vec![
3780            ("", false, "Empty path is treated as project root"),
3781            ("/", true, "Root directory should be outside project"),
3782            (
3783                "project/../other",
3784                true,
3785                "Path with .. that goes outside of root directory",
3786            ),
3787            (
3788                "project/./src/file.rs",
3789                false,
3790                "Path with . should work normally",
3791            ),
3792            #[cfg(target_os = "windows")]
3793            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
3794            #[cfg(target_os = "windows")]
3795            ("project\\src\\main.rs", false, "Windows-style project path"),
3796        ];
3797
3798        for (path, should_confirm, description) in test_cases {
3799            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3800            let auth =
3801                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
3802
3803            cx.run_until_parked();
3804
3805            if should_confirm {
3806                stream_rx.expect_authorization().await;
3807            } else {
3808                assert!(
3809                    stream_rx.try_next().is_err(),
3810                    "Failed for case: {} - path: {} - expected no confirmation but got one",
3811                    description,
3812                    path
3813                );
3814                auth.await.unwrap();
3815            }
3816        }
3817    }
3818
3819    #[gpui::test]
3820    async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
3821        init_test(cx);
3822        let fs = project::FakeFs::new(cx.executor());
3823        fs.insert_tree(
3824            "/project",
3825            json!({
3826                "existing.txt": "content",
3827                ".zed": {
3828                    "settings.json": "{}"
3829                }
3830            }),
3831        )
3832        .await;
3833        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
3834        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3835        let context_server_registry =
3836            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3837        let model = Arc::new(FakeLanguageModel::default());
3838        let thread = cx.new(|cx| {
3839            crate::Thread::new(
3840                project.clone(),
3841                cx.new(|_cx| ProjectContext::default()),
3842                context_server_registry.clone(),
3843                Templates::new(),
3844                Some(model.clone()),
3845                cx,
3846            )
3847        });
3848        let tool = Arc::new(StreamingEditFileTool::new(
3849            project.clone(),
3850            thread.downgrade(),
3851            language_registry,
3852        ));
3853
3854        let modes = vec![
3855            StreamingEditFileMode::Edit,
3856            StreamingEditFileMode::Create,
3857            StreamingEditFileMode::Overwrite,
3858        ];
3859
3860        for _mode in modes {
3861            // Test .zed path with different modes
3862            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3863            let _auth = cx.update(|cx| {
3864                tool.authorize(
3865                    &PathBuf::from("project/.zed/settings.json"),
3866                    "Edit settings",
3867                    &stream_tx,
3868                    cx,
3869                )
3870            });
3871
3872            stream_rx.expect_authorization().await;
3873
3874            // Test outside path with different modes
3875            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3876            let _auth = cx.update(|cx| {
3877                tool.authorize(
3878                    &PathBuf::from("/outside/file.txt"),
3879                    "Edit file",
3880                    &stream_tx,
3881                    cx,
3882                )
3883            });
3884
3885            stream_rx.expect_authorization().await;
3886
3887            // Test normal path with different modes
3888            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3889            cx.update(|cx| {
3890                tool.authorize(
3891                    &PathBuf::from("project/normal.txt"),
3892                    "Edit file",
3893                    &stream_tx,
3894                    cx,
3895                )
3896            })
3897            .await
3898            .unwrap();
3899            assert!(stream_rx.try_next().is_err());
3900        }
3901    }
3902
3903    #[gpui::test]
3904    async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) {
3905        init_test(cx);
3906        let fs = project::FakeFs::new(cx.executor());
3907        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
3908        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3909        let context_server_registry =
3910            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3911        let model = Arc::new(FakeLanguageModel::default());
3912        let thread = cx.new(|cx| {
3913            crate::Thread::new(
3914                project.clone(),
3915                cx.new(|_cx| ProjectContext::default()),
3916                context_server_registry,
3917                Templates::new(),
3918                Some(model.clone()),
3919                cx,
3920            )
3921        });
3922        let tool = Arc::new(StreamingEditFileTool::new(
3923            project,
3924            thread.downgrade(),
3925            language_registry,
3926        ));
3927
3928        cx.update(|cx| {
3929            assert_eq!(
3930                tool.initial_title(
3931                    Err(json!({
3932                        "path": "src/main.rs",
3933                        "display_description": "",
3934                    })),
3935                    cx
3936                ),
3937                "src/main.rs"
3938            );
3939            assert_eq!(
3940                tool.initial_title(
3941                    Err(json!({
3942                        "path": "",
3943                        "display_description": "Fix error handling",
3944                    })),
3945                    cx
3946                ),
3947                "Fix error handling"
3948            );
3949            assert_eq!(
3950                tool.initial_title(
3951                    Err(json!({
3952                        "path": "src/main.rs",
3953                        "display_description": "Fix error handling",
3954                    })),
3955                    cx
3956                ),
3957                "src/main.rs"
3958            );
3959            assert_eq!(
3960                tool.initial_title(
3961                    Err(json!({
3962                        "path": "",
3963                        "display_description": "",
3964                    })),
3965                    cx
3966                ),
3967                DEFAULT_UI_TEXT
3968            );
3969            assert_eq!(
3970                tool.initial_title(Err(serde_json::Value::Null), cx),
3971                DEFAULT_UI_TEXT
3972            );
3973        });
3974    }
3975
3976    #[gpui::test]
3977    async fn test_streaming_diff_finalization(cx: &mut TestAppContext) {
3978        init_test(cx);
3979        let fs = project::FakeFs::new(cx.executor());
3980        fs.insert_tree("/", json!({"main.rs": ""})).await;
3981
3982        let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
3983        let languages = project.read_with(cx, |project, _cx| project.languages().clone());
3984        let context_server_registry =
3985            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3986        let model = Arc::new(FakeLanguageModel::default());
3987        let thread = cx.new(|cx| {
3988            crate::Thread::new(
3989                project.clone(),
3990                cx.new(|_cx| ProjectContext::default()),
3991                context_server_registry.clone(),
3992                Templates::new(),
3993                Some(model.clone()),
3994                cx,
3995            )
3996        });
3997
3998        // Ensure the diff is finalized after the edit completes.
3999        {
4000            let tool = Arc::new(StreamingEditFileTool::new(
4001                project.clone(),
4002                thread.downgrade(),
4003                languages.clone(),
4004            ));
4005            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
4006            let edit = cx.update(|cx| {
4007                tool.run(
4008                    ToolInput::resolved(StreamingEditFileToolInput {
4009                        display_description: "Edit file".into(),
4010                        path: path!("/main.rs").into(),
4011                        mode: StreamingEditFileMode::Overwrite,
4012                        content: Some("new content".into()),
4013                        edits: None,
4014                    }),
4015                    stream_tx,
4016                    cx,
4017                )
4018            });
4019            stream_rx.expect_update_fields().await;
4020            let diff = stream_rx.expect_diff().await;
4021            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
4022            cx.run_until_parked();
4023            edit.await.unwrap();
4024            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
4025        }
4026
4027        // Ensure the diff is finalized if the tool call gets dropped.
4028        {
4029            let tool = Arc::new(StreamingEditFileTool::new(
4030                project.clone(),
4031                thread.downgrade(),
4032                languages.clone(),
4033            ));
4034            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
4035            let edit = cx.update(|cx| {
4036                tool.run(
4037                    ToolInput::resolved(StreamingEditFileToolInput {
4038                        display_description: "Edit file".into(),
4039                        path: path!("/main.rs").into(),
4040                        mode: StreamingEditFileMode::Overwrite,
4041                        content: Some("dropped content".into()),
4042                        edits: None,
4043                    }),
4044                    stream_tx,
4045                    cx,
4046                )
4047            });
4048            stream_rx.expect_update_fields().await;
4049            let diff = stream_rx.expect_diff().await;
4050            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
4051            drop(edit);
4052            cx.run_until_parked();
4053            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
4054        }
4055    }
4056
4057    #[gpui::test]
4058    async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) {
4059        init_test(cx);
4060
4061        let fs = project::FakeFs::new(cx.executor());
4062        fs.insert_tree(
4063            "/root",
4064            json!({
4065                "test.txt": "original content"
4066            }),
4067        )
4068        .await;
4069        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4070        let context_server_registry =
4071            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4072        let model = Arc::new(FakeLanguageModel::default());
4073        let thread = cx.new(|cx| {
4074            crate::Thread::new(
4075                project.clone(),
4076                cx.new(|_cx| ProjectContext::default()),
4077                context_server_registry,
4078                Templates::new(),
4079                Some(model.clone()),
4080                cx,
4081            )
4082        });
4083        let languages = project.read_with(cx, |project, _| project.languages().clone());
4084        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
4085
4086        let read_tool = Arc::new(crate::ReadFileTool::new(
4087            thread.downgrade(),
4088            project.clone(),
4089            action_log,
4090        ));
4091        let edit_tool = Arc::new(StreamingEditFileTool::new(
4092            project.clone(),
4093            thread.downgrade(),
4094            languages,
4095        ));
4096
4097        // Read the file first
4098        cx.update(|cx| {
4099            read_tool.clone().run(
4100                ToolInput::resolved(crate::ReadFileToolInput {
4101                    path: "root/test.txt".to_string(),
4102                    start_line: None,
4103                    end_line: None,
4104                }),
4105                ToolCallEventStream::test().0,
4106                cx,
4107            )
4108        })
4109        .await
4110        .unwrap();
4111
4112        // First edit should work
4113        let edit_result = cx
4114            .update(|cx| {
4115                edit_tool.clone().run(
4116                    ToolInput::resolved(StreamingEditFileToolInput {
4117                        display_description: "First edit".into(),
4118                        path: "root/test.txt".into(),
4119                        mode: StreamingEditFileMode::Edit,
4120                        content: None,
4121                        edits: Some(vec![EditOperation {
4122                            old_text: "original content".into(),
4123                            new_text: "modified content".into(),
4124                        }]),
4125                    }),
4126                    ToolCallEventStream::test().0,
4127                    cx,
4128                )
4129            })
4130            .await;
4131        assert!(
4132            edit_result.is_ok(),
4133            "First edit should succeed, got error: {:?}",
4134            edit_result.as_ref().err()
4135        );
4136
4137        // Second edit should also work because the edit updated the recorded read time
4138        let edit_result = cx
4139            .update(|cx| {
4140                edit_tool.clone().run(
4141                    ToolInput::resolved(StreamingEditFileToolInput {
4142                        display_description: "Second edit".into(),
4143                        path: "root/test.txt".into(),
4144                        mode: StreamingEditFileMode::Edit,
4145                        content: None,
4146                        edits: Some(vec![EditOperation {
4147                            old_text: "modified content".into(),
4148                            new_text: "further modified content".into(),
4149                        }]),
4150                    }),
4151                    ToolCallEventStream::test().0,
4152                    cx,
4153                )
4154            })
4155            .await;
4156        assert!(
4157            edit_result.is_ok(),
4158            "Second consecutive edit should succeed, got error: {:?}",
4159            edit_result.as_ref().err()
4160        );
4161    }
4162
4163    #[gpui::test]
4164    async fn test_streaming_external_modification_detected(cx: &mut TestAppContext) {
4165        init_test(cx);
4166
4167        let fs = project::FakeFs::new(cx.executor());
4168        fs.insert_tree(
4169            "/root",
4170            json!({
4171                "test.txt": "original content"
4172            }),
4173        )
4174        .await;
4175        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4176        let context_server_registry =
4177            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4178        let model = Arc::new(FakeLanguageModel::default());
4179        let thread = cx.new(|cx| {
4180            crate::Thread::new(
4181                project.clone(),
4182                cx.new(|_cx| ProjectContext::default()),
4183                context_server_registry,
4184                Templates::new(),
4185                Some(model.clone()),
4186                cx,
4187            )
4188        });
4189        let languages = project.read_with(cx, |project, _| project.languages().clone());
4190        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
4191
4192        let read_tool = Arc::new(crate::ReadFileTool::new(
4193            thread.downgrade(),
4194            project.clone(),
4195            action_log,
4196        ));
4197        let edit_tool = Arc::new(StreamingEditFileTool::new(
4198            project.clone(),
4199            thread.downgrade(),
4200            languages,
4201        ));
4202
4203        // Read the file first
4204        cx.update(|cx| {
4205            read_tool.clone().run(
4206                ToolInput::resolved(crate::ReadFileToolInput {
4207                    path: "root/test.txt".to_string(),
4208                    start_line: None,
4209                    end_line: None,
4210                }),
4211                ToolCallEventStream::test().0,
4212                cx,
4213            )
4214        })
4215        .await
4216        .unwrap();
4217
4218        // Simulate external modification
4219        cx.background_executor
4220            .advance_clock(std::time::Duration::from_secs(2));
4221        fs.save(
4222            path!("/root/test.txt").as_ref(),
4223            &"externally modified content".into(),
4224            language::LineEnding::Unix,
4225        )
4226        .await
4227        .unwrap();
4228
4229        // Reload the buffer to pick up the new mtime
4230        let project_path = project
4231            .read_with(cx, |project, cx| {
4232                project.find_project_path("root/test.txt", cx)
4233            })
4234            .expect("Should find project path");
4235        let buffer = project
4236            .update(cx, |project, cx| project.open_buffer(project_path, cx))
4237            .await
4238            .unwrap();
4239        buffer
4240            .update(cx, |buffer, cx| buffer.reload(cx))
4241            .await
4242            .unwrap();
4243
4244        cx.executor().run_until_parked();
4245
4246        // Try to edit - should fail because file was modified externally
4247        let result = cx
4248            .update(|cx| {
4249                edit_tool.clone().run(
4250                    ToolInput::resolved(StreamingEditFileToolInput {
4251                        display_description: "Edit after external change".into(),
4252                        path: "root/test.txt".into(),
4253                        mode: StreamingEditFileMode::Edit,
4254                        content: None,
4255                        edits: Some(vec![EditOperation {
4256                            old_text: "externally modified content".into(),
4257                            new_text: "new content".into(),
4258                        }]),
4259                    }),
4260                    ToolCallEventStream::test().0,
4261                    cx,
4262                )
4263            })
4264            .await;
4265
4266        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
4267            panic!("expected error");
4268        };
4269        assert!(
4270            error.contains("has been modified since you last read it"),
4271            "Error should mention file modification, got: {}",
4272            error
4273        );
4274    }
4275
4276    #[gpui::test]
4277    async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) {
4278        init_test(cx);
4279
4280        let fs = project::FakeFs::new(cx.executor());
4281        fs.insert_tree(
4282            "/root",
4283            json!({
4284                "test.txt": "original content"
4285            }),
4286        )
4287        .await;
4288        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4289        let context_server_registry =
4290            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4291        let model = Arc::new(FakeLanguageModel::default());
4292        let thread = cx.new(|cx| {
4293            crate::Thread::new(
4294                project.clone(),
4295                cx.new(|_cx| ProjectContext::default()),
4296                context_server_registry,
4297                Templates::new(),
4298                Some(model.clone()),
4299                cx,
4300            )
4301        });
4302        let languages = project.read_with(cx, |project, _| project.languages().clone());
4303        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
4304
4305        let read_tool = Arc::new(crate::ReadFileTool::new(
4306            thread.downgrade(),
4307            project.clone(),
4308            action_log,
4309        ));
4310        let edit_tool = Arc::new(StreamingEditFileTool::new(
4311            project.clone(),
4312            thread.downgrade(),
4313            languages,
4314        ));
4315
4316        // Read the file first
4317        cx.update(|cx| {
4318            read_tool.clone().run(
4319                ToolInput::resolved(crate::ReadFileToolInput {
4320                    path: "root/test.txt".to_string(),
4321                    start_line: None,
4322                    end_line: None,
4323                }),
4324                ToolCallEventStream::test().0,
4325                cx,
4326            )
4327        })
4328        .await
4329        .unwrap();
4330
4331        // Open the buffer and make it dirty
4332        let project_path = project
4333            .read_with(cx, |project, cx| {
4334                project.find_project_path("root/test.txt", cx)
4335            })
4336            .expect("Should find project path");
4337        let buffer = project
4338            .update(cx, |project, cx| project.open_buffer(project_path, cx))
4339            .await
4340            .unwrap();
4341
4342        buffer.update(cx, |buffer, cx| {
4343            let end_point = buffer.max_point();
4344            buffer.edit([(end_point..end_point, " added text")], None, cx);
4345        });
4346
4347        let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
4348        assert!(is_dirty, "Buffer should be dirty after in-memory edit");
4349
4350        // Try to edit - should fail because buffer has unsaved changes
4351        let result = cx
4352            .update(|cx| {
4353                edit_tool.clone().run(
4354                    ToolInput::resolved(StreamingEditFileToolInput {
4355                        display_description: "Edit with dirty buffer".into(),
4356                        path: "root/test.txt".into(),
4357                        mode: StreamingEditFileMode::Edit,
4358                        content: None,
4359                        edits: Some(vec![EditOperation {
4360                            old_text: "original content".into(),
4361                            new_text: "new content".into(),
4362                        }]),
4363                    }),
4364                    ToolCallEventStream::test().0,
4365                    cx,
4366                )
4367            })
4368            .await;
4369
4370        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
4371            panic!("expected error");
4372        };
4373        assert!(
4374            error.contains("This file has unsaved changes."),
4375            "Error should mention unsaved changes, got: {}",
4376            error
4377        );
4378        assert!(
4379            error.contains("keep or discard"),
4380            "Error should ask whether to keep or discard changes, got: {}",
4381            error
4382        );
4383        assert!(
4384            error.contains("save or revert the file manually"),
4385            "Error should ask user to manually save or revert when tools aren't available, got: {}",
4386            error
4387        );
4388    }
4389
4390    #[gpui::test]
4391    async fn test_streaming_overlapping_edits_detected_early(cx: &mut TestAppContext) {
4392        init_test(cx);
4393
4394        let fs = project::FakeFs::new(cx.executor());
4395        // The file content is crafted so that edit 1's replacement still
4396        // contains the old_text of edit 2 as a contiguous substring.
4397        // Without early overlap detection, edit 2 would silently match
4398        // inside the already-modified region and corrupt the file instead
4399        // of producing a clear "Conflicting edit ranges" error.
4400        fs.insert_tree(
4401            "/root",
4402            json!({
4403                "file.txt": "aaa\nbbb\nccc\nddd\neee\n"
4404            }),
4405        )
4406        .await;
4407        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4408        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
4409        let context_server_registry =
4410            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4411        let model = Arc::new(FakeLanguageModel::default());
4412        let thread = cx.new(|cx| {
4413            crate::Thread::new(
4414                project.clone(),
4415                cx.new(|_cx| ProjectContext::default()),
4416                context_server_registry,
4417                Templates::new(),
4418                Some(model),
4419                cx,
4420            )
4421        });
4422
4423        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
4424        let (event_stream, _receiver) = ToolCallEventStream::test();
4425
4426        let tool = Arc::new(StreamingEditFileTool::new(
4427            project.clone(),
4428            thread.downgrade(),
4429            language_registry,
4430        ));
4431
4432        let task = cx.update(|cx| tool.run(input, event_stream, cx));
4433
4434        // Setup: resolve the buffer
4435        sender.send_partial(json!({
4436            "display_description": "Overlapping edits",
4437            "path": "root/file.txt",
4438            "mode": "edit"
4439        }));
4440        cx.run_until_parked();
4441
4442        // Edit 1 targets "bbb\nccc" (lines 2-3) and replaces it with
4443        // text that preserves "ccc\nddd" as a contiguous substring in the
4444        // buffer — so edit 2's old_text will still match after edit 1 is
4445        // applied.
4446        //
4447        // Edit 2 targets "ccc\nddd" (lines 3-4), overlapping with edit 1 on
4448        // line 3 ("ccc"). After edit 1 runs, the buffer becomes:
4449        //   "aaa\nXXX\nccc\nddd\nddd\neee\n"
4450        // and "ccc\nddd" is still present, so edit 2 would silently
4451        // succeed without early overlap detection.
4452        //
4453        // Edit 3 exists only to mark edit 2 as "complete" during streaming.
4454        sender.send_partial(json!({
4455            "display_description": "Overlapping edits",
4456            "path": "root/file.txt",
4457            "mode": "edit",
4458            "edits": [
4459                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
4460                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
4461                {"old_text": "eee", "new_text": "DUMMY"}
4462            ]
4463        }));
4464        cx.run_until_parked();
4465
4466        // Send the final input with all three edits.
4467        sender.send_final(json!({
4468            "display_description": "Overlapping edits",
4469            "path": "root/file.txt",
4470            "mode": "edit",
4471            "edits": [
4472                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
4473                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
4474                {"old_text": "eee", "new_text": "DUMMY"}
4475            ]
4476        }));
4477
4478        let result = task.await;
4479        // We expect a "Conflicting edit ranges" error. Currently the overlap
4480        // goes undetected during streaming and the file gets silently
4481        // corrupted, so this assertion will fail until we add early overlap
4482        // detection.
4483        match result {
4484            Err(StreamingEditFileToolOutput::Error { error })
4485                if error.contains("Conflicting edit ranges") => {}
4486            Err(StreamingEditFileToolOutput::Error { error }) => {
4487                panic!("Expected 'Conflicting edit ranges' error, got different error: {error}");
4488            }
4489            Ok(output) => {
4490                panic!("Expected 'Conflicting edit ranges' error, but got success: {output}");
4491            }
4492            Err(other) => {
4493                panic!("Expected 'Conflicting edit ranges' error, got unexpected output: {other}");
4494            }
4495        }
4496    }
4497
4498    fn init_test(cx: &mut TestAppContext) {
4499        cx.update(|cx| {
4500            let settings_store = SettingsStore::test(cx);
4501            cx.set_global(settings_store);
4502            SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
4503                store.update_user_settings(cx, |settings| {
4504                    settings
4505                        .project
4506                        .all_languages
4507                        .defaults
4508                        .ensure_final_newline_on_save = Some(false);
4509                });
4510            });
4511        });
4512    }
4513}