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