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