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