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