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