streaming_edit_file_tool.rs

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