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