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