streaming_edit_file_tool.rs

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