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