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