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