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