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