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