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::PermissionOptionId::new("allow"))
2585            .unwrap();
2586        authorize_task.await.unwrap();
2587    }
2588
2589    #[gpui::test]
2590    async fn test_streaming_edit_file_symlink_escape_requests_authorization(
2591        cx: &mut TestAppContext,
2592    ) {
2593        init_test(cx);
2594
2595        let fs = project::FakeFs::new(cx.executor());
2596        fs.insert_tree(
2597            path!("/root"),
2598            json!({
2599                "src": { "main.rs": "fn main() {}" }
2600            }),
2601        )
2602        .await;
2603        fs.insert_tree(
2604            path!("/outside"),
2605            json!({
2606                "config.txt": "old content"
2607            }),
2608        )
2609        .await;
2610        fs.create_symlink(
2611            path!("/root/link_to_external").as_ref(),
2612            PathBuf::from("/outside"),
2613        )
2614        .await
2615        .unwrap();
2616        let (tool, _project, _action_log, _fs, _thread) =
2617            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2618
2619        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2620        let _authorize_task = cx.update(|cx| {
2621            tool.authorize(
2622                &PathBuf::from("link_to_external/config.txt"),
2623                "edit through symlink",
2624                &stream_tx,
2625                cx,
2626            )
2627        });
2628
2629        let auth = stream_rx.expect_authorization().await;
2630        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
2631        assert!(
2632            title.contains("points outside the project"),
2633            "title should mention symlink escape, got: {title}"
2634        );
2635    }
2636
2637    #[gpui::test]
2638    async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
2639        init_test(cx);
2640
2641        let fs = project::FakeFs::new(cx.executor());
2642        fs.insert_tree(
2643            path!("/root"),
2644            json!({
2645                "src": { "main.rs": "fn main() {}" }
2646            }),
2647        )
2648        .await;
2649        fs.insert_tree(
2650            path!("/outside"),
2651            json!({
2652                "config.txt": "old content"
2653            }),
2654        )
2655        .await;
2656        fs.create_symlink(
2657            path!("/root/link_to_external").as_ref(),
2658            PathBuf::from("/outside"),
2659        )
2660        .await
2661        .unwrap();
2662        let (tool, _project, _action_log, _fs, _thread) =
2663            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2664
2665        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2666        let authorize_task = cx.update(|cx| {
2667            tool.authorize(
2668                &PathBuf::from("link_to_external/config.txt"),
2669                "edit through symlink",
2670                &stream_tx,
2671                cx,
2672            )
2673        });
2674
2675        let auth = stream_rx.expect_authorization().await;
2676        drop(auth); // deny by dropping
2677
2678        let result = authorize_task.await;
2679        assert!(result.is_err(), "should fail when denied");
2680    }
2681
2682    #[gpui::test]
2683    async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
2684        init_test(cx);
2685        cx.update(|cx| {
2686            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2687            settings.tool_permissions.tools.insert(
2688                "edit_file".into(),
2689                agent_settings::ToolRules {
2690                    default: Some(settings::ToolPermissionMode::Deny),
2691                    ..Default::default()
2692                },
2693            );
2694            agent_settings::AgentSettings::override_global(settings, cx);
2695        });
2696
2697        let fs = project::FakeFs::new(cx.executor());
2698        fs.insert_tree(
2699            path!("/root"),
2700            json!({
2701                "src": { "main.rs": "fn main() {}" }
2702            }),
2703        )
2704        .await;
2705        fs.insert_tree(
2706            path!("/outside"),
2707            json!({
2708                "config.txt": "old content"
2709            }),
2710        )
2711        .await;
2712        fs.create_symlink(
2713            path!("/root/link_to_external").as_ref(),
2714            PathBuf::from("/outside"),
2715        )
2716        .await
2717        .unwrap();
2718        let (tool, _project, _action_log, _fs, _thread) =
2719            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2720
2721        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2722        let result = cx
2723            .update(|cx| {
2724                tool.authorize(
2725                    &PathBuf::from("link_to_external/config.txt"),
2726                    "edit through symlink",
2727                    &stream_tx,
2728                    cx,
2729                )
2730            })
2731            .await;
2732
2733        assert!(result.is_err(), "Tool should fail when policy denies");
2734        assert!(
2735            !matches!(
2736                stream_rx.try_next(),
2737                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
2738            ),
2739            "Deny policy should not emit symlink authorization prompt",
2740        );
2741    }
2742
2743    #[gpui::test]
2744    async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) {
2745        init_test(cx);
2746        let fs = project::FakeFs::new(cx.executor());
2747        fs.insert_tree("/project", json!({})).await;
2748        let (tool, _project, _action_log, _fs, _thread) =
2749            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2750
2751        let test_cases = vec![
2752            (
2753                "/etc/hosts",
2754                true,
2755                "System file should require confirmation",
2756            ),
2757            (
2758                "/usr/local/bin/script",
2759                true,
2760                "System bin file should require confirmation",
2761            ),
2762            (
2763                "project/normal_file.rs",
2764                false,
2765                "Normal project file should not require confirmation",
2766            ),
2767        ];
2768
2769        for (path, should_confirm, description) in test_cases {
2770            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2771            let auth =
2772                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2773
2774            if should_confirm {
2775                stream_rx.expect_authorization().await;
2776            } else {
2777                auth.await.unwrap();
2778                assert!(
2779                    stream_rx.try_next().is_err(),
2780                    "Failed for case: {} - path: {} - expected no confirmation but got one",
2781                    description,
2782                    path
2783                );
2784            }
2785        }
2786    }
2787
2788    #[gpui::test]
2789    async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2790        init_test(cx);
2791        let fs = project::FakeFs::new(cx.executor());
2792        fs.insert_tree(
2793            "/workspace/frontend",
2794            json!({
2795                "src": {
2796                    "main.js": "console.log('frontend');"
2797                }
2798            }),
2799        )
2800        .await;
2801        fs.insert_tree(
2802            "/workspace/backend",
2803            json!({
2804                "src": {
2805                    "main.rs": "fn main() {}"
2806                }
2807            }),
2808        )
2809        .await;
2810        fs.insert_tree(
2811            "/workspace/shared",
2812            json!({
2813                ".zed": {
2814                    "settings.json": "{}"
2815                }
2816            }),
2817        )
2818        .await;
2819        let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(
2820            cx,
2821            fs,
2822            &[
2823                path!("/workspace/frontend").as_ref(),
2824                path!("/workspace/backend").as_ref(),
2825                path!("/workspace/shared").as_ref(),
2826            ],
2827        )
2828        .await;
2829
2830        let test_cases = vec![
2831            ("frontend/src/main.js", false, "File in first worktree"),
2832            ("backend/src/main.rs", false, "File in second worktree"),
2833            (
2834                "shared/.zed/settings.json",
2835                true,
2836                ".zed file in third worktree",
2837            ),
2838            ("/etc/hosts", true, "Absolute path outside all worktrees"),
2839            (
2840                "../outside/file.txt",
2841                true,
2842                "Relative path outside worktrees",
2843            ),
2844        ];
2845
2846        for (path, should_confirm, description) in test_cases {
2847            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2848            let auth =
2849                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2850
2851            if should_confirm {
2852                stream_rx.expect_authorization().await;
2853            } else {
2854                auth.await.unwrap();
2855                assert!(
2856                    stream_rx.try_next().is_err(),
2857                    "Failed for case: {} - path: {} - expected no confirmation but got one",
2858                    description,
2859                    path
2860                );
2861            }
2862        }
2863    }
2864
2865    #[gpui::test]
2866    async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2867        init_test(cx);
2868        let fs = project::FakeFs::new(cx.executor());
2869        fs.insert_tree(
2870            "/project",
2871            json!({
2872                ".zed": {
2873                    "settings.json": "{}"
2874                },
2875                "src": {
2876                    ".zed": {
2877                        "local.json": "{}"
2878                    }
2879                }
2880            }),
2881        )
2882        .await;
2883        let (tool, _project, _action_log, _fs, _thread) =
2884            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2885
2886        let test_cases = vec![
2887            ("", false, "Empty path is treated as project root"),
2888            ("/", true, "Root directory should be outside project"),
2889            (
2890                "project/../other",
2891                true,
2892                "Path with .. that goes outside of root directory",
2893            ),
2894            (
2895                "project/./src/file.rs",
2896                false,
2897                "Path with . should work normally",
2898            ),
2899            #[cfg(target_os = "windows")]
2900            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2901            #[cfg(target_os = "windows")]
2902            ("project\\src\\main.rs", false, "Windows-style project path"),
2903        ];
2904
2905        for (path, should_confirm, description) in test_cases {
2906            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2907            let auth =
2908                cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2909
2910            cx.run_until_parked();
2911
2912            if should_confirm {
2913                stream_rx.expect_authorization().await;
2914            } else {
2915                assert!(
2916                    stream_rx.try_next().is_err(),
2917                    "Failed for case: {} - path: {} - expected no confirmation but got one",
2918                    description,
2919                    path
2920                );
2921                auth.await.unwrap();
2922            }
2923        }
2924    }
2925
2926    #[gpui::test]
2927    async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2928        init_test(cx);
2929        let fs = project::FakeFs::new(cx.executor());
2930        fs.insert_tree(
2931            "/project",
2932            json!({
2933                "existing.txt": "content",
2934                ".zed": {
2935                    "settings.json": "{}"
2936                }
2937            }),
2938        )
2939        .await;
2940        let (tool, _project, _action_log, _fs, _thread) =
2941            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2942
2943        let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write];
2944
2945        for _mode in modes {
2946            // Test .zed path with different modes
2947            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2948            let _auth = cx.update(|cx| {
2949                tool.authorize(
2950                    &PathBuf::from("project/.zed/settings.json"),
2951                    "Edit settings",
2952                    &stream_tx,
2953                    cx,
2954                )
2955            });
2956
2957            stream_rx.expect_authorization().await;
2958
2959            // Test outside path with different modes
2960            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2961            let _auth = cx.update(|cx| {
2962                tool.authorize(
2963                    &PathBuf::from("/outside/file.txt"),
2964                    "Edit file",
2965                    &stream_tx,
2966                    cx,
2967                )
2968            });
2969
2970            stream_rx.expect_authorization().await;
2971
2972            // Test normal path with different modes
2973            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2974            cx.update(|cx| {
2975                tool.authorize(
2976                    &PathBuf::from("project/normal.txt"),
2977                    "Edit file",
2978                    &stream_tx,
2979                    cx,
2980                )
2981            })
2982            .await
2983            .unwrap();
2984            assert!(stream_rx.try_next().is_err());
2985        }
2986    }
2987
2988    #[gpui::test]
2989    async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) {
2990        init_test(cx);
2991        let fs = project::FakeFs::new(cx.executor());
2992        fs.insert_tree("/project", json!({})).await;
2993        let (tool, _project, _action_log, _fs, _thread) =
2994            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2995
2996        cx.update(|cx| {
2997            assert_eq!(
2998                tool.initial_title(
2999                    Err(json!({
3000                        "path": "src/main.rs",
3001                        "display_description": "",
3002                    })),
3003                    cx
3004                ),
3005                "src/main.rs"
3006            );
3007            assert_eq!(
3008                tool.initial_title(
3009                    Err(json!({
3010                        "path": "",
3011                        "display_description": "Fix error handling",
3012                    })),
3013                    cx
3014                ),
3015                "Fix error handling"
3016            );
3017            assert_eq!(
3018                tool.initial_title(
3019                    Err(json!({
3020                        "path": "src/main.rs",
3021                        "display_description": "Fix error handling",
3022                    })),
3023                    cx
3024                ),
3025                "src/main.rs"
3026            );
3027            assert_eq!(
3028                tool.initial_title(
3029                    Err(json!({
3030                        "path": "",
3031                        "display_description": "",
3032                    })),
3033                    cx
3034                ),
3035                DEFAULT_UI_TEXT
3036            );
3037            assert_eq!(
3038                tool.initial_title(Err(serde_json::Value::Null), cx),
3039                DEFAULT_UI_TEXT
3040            );
3041        });
3042    }
3043
3044    #[gpui::test]
3045    async fn test_streaming_diff_finalization(cx: &mut TestAppContext) {
3046        init_test(cx);
3047        let fs = project::FakeFs::new(cx.executor());
3048        fs.insert_tree("/", json!({"main.rs": ""})).await;
3049        let (tool, project, action_log, _fs, thread) =
3050            setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await;
3051        let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
3052
3053        // Ensure the diff is finalized after the edit completes.
3054        {
3055            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3056            let edit = cx.update(|cx| {
3057                tool.clone().run(
3058                    ToolInput::resolved(StreamingEditFileToolInput {
3059                        display_description: "Edit file".into(),
3060                        path: path!("/main.rs").into(),
3061                        mode: StreamingEditFileMode::Write,
3062                        content: Some("new content".into()),
3063                        edits: None,
3064                    }),
3065                    stream_tx,
3066                    cx,
3067                )
3068            });
3069            stream_rx.expect_update_fields().await;
3070            let diff = stream_rx.expect_diff().await;
3071            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3072            cx.run_until_parked();
3073            edit.await.unwrap();
3074            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3075        }
3076
3077        // Ensure the diff is finalized if the tool call gets dropped.
3078        {
3079            let tool = Arc::new(StreamingEditFileTool::new(
3080                project.clone(),
3081                thread.downgrade(),
3082                action_log,
3083                language_registry,
3084            ));
3085            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3086            let edit = cx.update(|cx| {
3087                tool.run(
3088                    ToolInput::resolved(StreamingEditFileToolInput {
3089                        display_description: "Edit file".into(),
3090                        path: path!("/main.rs").into(),
3091                        mode: StreamingEditFileMode::Write,
3092                        content: Some("dropped content".into()),
3093                        edits: None,
3094                    }),
3095                    stream_tx,
3096                    cx,
3097                )
3098            });
3099            stream_rx.expect_update_fields().await;
3100            let diff = stream_rx.expect_diff().await;
3101            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3102            drop(edit);
3103            cx.run_until_parked();
3104            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3105        }
3106    }
3107
3108    #[gpui::test]
3109    async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) {
3110        let (tool, project, action_log, _fs, _thread) =
3111            setup_test(cx, json!({"test.txt": "original content"})).await;
3112        let read_tool = Arc::new(crate::ReadFileTool::new(
3113            project.clone(),
3114            action_log.clone(),
3115            true,
3116        ));
3117
3118        // Read the file first
3119        cx.update(|cx| {
3120            read_tool.clone().run(
3121                ToolInput::resolved(crate::ReadFileToolInput {
3122                    path: "root/test.txt".to_string(),
3123                    start_line: None,
3124                    end_line: None,
3125                }),
3126                ToolCallEventStream::test().0,
3127                cx,
3128            )
3129        })
3130        .await
3131        .unwrap();
3132
3133        // First edit should work
3134        let edit_result = cx
3135            .update(|cx| {
3136                tool.clone().run(
3137                    ToolInput::resolved(StreamingEditFileToolInput {
3138                        display_description: "First edit".into(),
3139                        path: "root/test.txt".into(),
3140                        mode: StreamingEditFileMode::Edit,
3141                        content: None,
3142                        edits: Some(vec![Edit {
3143                            old_text: "original content".into(),
3144                            new_text: "modified content".into(),
3145                        }]),
3146                    }),
3147                    ToolCallEventStream::test().0,
3148                    cx,
3149                )
3150            })
3151            .await;
3152        assert!(
3153            edit_result.is_ok(),
3154            "First edit should succeed, got error: {:?}",
3155            edit_result.as_ref().err()
3156        );
3157
3158        // Second edit should also work because the edit updated the recorded read time
3159        let edit_result = cx
3160            .update(|cx| {
3161                tool.clone().run(
3162                    ToolInput::resolved(StreamingEditFileToolInput {
3163                        display_description: "Second edit".into(),
3164                        path: "root/test.txt".into(),
3165                        mode: StreamingEditFileMode::Edit,
3166                        content: None,
3167                        edits: Some(vec![Edit {
3168                            old_text: "modified content".into(),
3169                            new_text: "further modified content".into(),
3170                        }]),
3171                    }),
3172                    ToolCallEventStream::test().0,
3173                    cx,
3174                )
3175            })
3176            .await;
3177        assert!(
3178            edit_result.is_ok(),
3179            "Second consecutive edit should succeed, got error: {:?}",
3180            edit_result.as_ref().err()
3181        );
3182    }
3183
3184    #[gpui::test]
3185    async fn test_streaming_external_modification_detected(cx: &mut TestAppContext) {
3186        let (tool, project, action_log, fs, _thread) =
3187            setup_test(cx, json!({"test.txt": "original content"})).await;
3188        let read_tool = Arc::new(crate::ReadFileTool::new(
3189            project.clone(),
3190            action_log.clone(),
3191            true,
3192        ));
3193
3194        // Read the file first
3195        cx.update(|cx| {
3196            read_tool.clone().run(
3197                ToolInput::resolved(crate::ReadFileToolInput {
3198                    path: "root/test.txt".to_string(),
3199                    start_line: None,
3200                    end_line: None,
3201                }),
3202                ToolCallEventStream::test().0,
3203                cx,
3204            )
3205        })
3206        .await
3207        .unwrap();
3208
3209        // Simulate external modification
3210        cx.background_executor
3211            .advance_clock(std::time::Duration::from_secs(2));
3212        fs.save(
3213            path!("/root/test.txt").as_ref(),
3214            &"externally modified content".into(),
3215            language::LineEnding::Unix,
3216        )
3217        .await
3218        .unwrap();
3219
3220        // Reload the buffer to pick up the new mtime
3221        let project_path = project
3222            .read_with(cx, |project, cx| {
3223                project.find_project_path("root/test.txt", cx)
3224            })
3225            .expect("Should find project path");
3226        let buffer = project
3227            .update(cx, |project, cx| project.open_buffer(project_path, cx))
3228            .await
3229            .unwrap();
3230        buffer
3231            .update(cx, |buffer, cx| buffer.reload(cx))
3232            .await
3233            .unwrap();
3234
3235        cx.executor().run_until_parked();
3236
3237        // Try to edit - should fail because file was modified externally
3238        let result = cx
3239            .update(|cx| {
3240                tool.clone().run(
3241                    ToolInput::resolved(StreamingEditFileToolInput {
3242                        display_description: "Edit after external change".into(),
3243                        path: "root/test.txt".into(),
3244                        mode: StreamingEditFileMode::Edit,
3245                        content: None,
3246                        edits: Some(vec![Edit {
3247                            old_text: "externally modified content".into(),
3248                            new_text: "new content".into(),
3249                        }]),
3250                    }),
3251                    ToolCallEventStream::test().0,
3252                    cx,
3253                )
3254            })
3255            .await;
3256
3257        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
3258            panic!("expected error");
3259        };
3260        assert!(
3261            error.contains("has been modified since you last read it"),
3262            "Error should mention file modification, got: {}",
3263            error
3264        );
3265    }
3266
3267    #[gpui::test]
3268    async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) {
3269        let (tool, project, action_log, _fs, _thread) =
3270            setup_test(cx, json!({"test.txt": "original content"})).await;
3271        let read_tool = Arc::new(crate::ReadFileTool::new(
3272            project.clone(),
3273            action_log.clone(),
3274            true,
3275        ));
3276
3277        // Read the file first
3278        cx.update(|cx| {
3279            read_tool.clone().run(
3280                ToolInput::resolved(crate::ReadFileToolInput {
3281                    path: "root/test.txt".to_string(),
3282                    start_line: None,
3283                    end_line: None,
3284                }),
3285                ToolCallEventStream::test().0,
3286                cx,
3287            )
3288        })
3289        .await
3290        .unwrap();
3291
3292        // Open the buffer and make it dirty
3293        let project_path = project
3294            .read_with(cx, |project, cx| {
3295                project.find_project_path("root/test.txt", cx)
3296            })
3297            .expect("Should find project path");
3298        let buffer = project
3299            .update(cx, |project, cx| project.open_buffer(project_path, cx))
3300            .await
3301            .unwrap();
3302
3303        buffer.update(cx, |buffer, cx| {
3304            let end_point = buffer.max_point();
3305            buffer.edit([(end_point..end_point, " added text")], None, cx);
3306        });
3307
3308        let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
3309        assert!(is_dirty, "Buffer should be dirty after in-memory edit");
3310
3311        // Try to edit - should fail because buffer has unsaved changes
3312        let result = cx
3313            .update(|cx| {
3314                tool.clone().run(
3315                    ToolInput::resolved(StreamingEditFileToolInput {
3316                        display_description: "Edit with dirty buffer".into(),
3317                        path: "root/test.txt".into(),
3318                        mode: StreamingEditFileMode::Edit,
3319                        content: None,
3320                        edits: Some(vec![Edit {
3321                            old_text: "original content".into(),
3322                            new_text: "new content".into(),
3323                        }]),
3324                    }),
3325                    ToolCallEventStream::test().0,
3326                    cx,
3327                )
3328            })
3329            .await;
3330
3331        let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
3332            panic!("expected error");
3333        };
3334        assert!(
3335            error.contains("This file has unsaved changes."),
3336            "Error should mention unsaved changes, got: {}",
3337            error
3338        );
3339        assert!(
3340            error.contains("keep or discard"),
3341            "Error should ask whether to keep or discard changes, got: {}",
3342            error
3343        );
3344        assert!(
3345            error.contains("save or revert the file manually"),
3346            "Error should ask user to manually save or revert when tools aren't available, got: {}",
3347            error
3348        );
3349    }
3350
3351    #[gpui::test]
3352    async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) {
3353        // Edit 1's replacement introduces text that contains edit 2's
3354        // old_text as a substring. Because edits resolve sequentially
3355        // against the current buffer, edit 2 finds a unique match in
3356        // the modified buffer and succeeds.
3357        let (tool, _project, _action_log, _fs, _thread) =
3358            setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
3359        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3360        let (event_stream, _receiver) = ToolCallEventStream::test();
3361        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3362
3363        // Setup: resolve the buffer
3364        sender.send_partial(json!({
3365            "display_description": "Overlapping edits",
3366            "path": "root/file.txt",
3367            "mode": "edit"
3368        }));
3369        cx.run_until_parked();
3370
3371        // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
3372        // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
3373        // Edit 2's old_text "ccc\nddd" matches the first occurrence
3374        // in the modified buffer and replaces it with "ZZZ".
3375        // Edit 3 exists only to mark edit 2 as "complete" during streaming.
3376        sender.send_partial(json!({
3377            "display_description": "Overlapping edits",
3378            "path": "root/file.txt",
3379            "mode": "edit",
3380            "edits": [
3381                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3382                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3383                {"old_text": "eee", "new_text": "DUMMY"}
3384            ]
3385        }));
3386        cx.run_until_parked();
3387
3388        // Send the final input with all three edits.
3389        sender.send_final(json!({
3390            "display_description": "Overlapping edits",
3391            "path": "root/file.txt",
3392            "mode": "edit",
3393            "edits": [
3394                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3395                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3396                {"old_text": "eee", "new_text": "DUMMY"}
3397            ]
3398        }));
3399
3400        let result = task.await;
3401        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3402            panic!("expected success");
3403        };
3404        assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
3405    }
3406
3407    #[gpui::test]
3408    async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) {
3409        let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
3410        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3411        let (event_stream, _receiver) = ToolCallEventStream::test();
3412        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3413
3414        // Transition to BufferResolved
3415        sender.send_partial(json!({
3416            "display_description": "Create new file",
3417            "path": "root/dir/new_file.txt",
3418            "mode": "write"
3419        }));
3420        cx.run_until_parked();
3421
3422        // Stream content incrementally
3423        sender.send_partial(json!({
3424            "display_description": "Create new file",
3425            "path": "root/dir/new_file.txt",
3426            "mode": "write",
3427            "content": "line 1\n"
3428        }));
3429        cx.run_until_parked();
3430
3431        // Verify buffer has partial content
3432        let buffer = project.update(cx, |project, cx| {
3433            let path = project
3434                .find_project_path("root/dir/new_file.txt", cx)
3435                .unwrap();
3436            project.get_open_buffer(&path, cx).unwrap()
3437        });
3438        assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n");
3439
3440        // Stream more content
3441        sender.send_partial(json!({
3442            "display_description": "Create new file",
3443            "path": "root/dir/new_file.txt",
3444            "mode": "write",
3445            "content": "line 1\nline 2\n"
3446        }));
3447        cx.run_until_parked();
3448        assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n");
3449
3450        // Stream final chunk
3451        sender.send_partial(json!({
3452            "display_description": "Create new file",
3453            "path": "root/dir/new_file.txt",
3454            "mode": "write",
3455            "content": "line 1\nline 2\nline 3\n"
3456        }));
3457        cx.run_until_parked();
3458        assert_eq!(
3459            buffer.read_with(cx, |b, _| b.text()),
3460            "line 1\nline 2\nline 3\n"
3461        );
3462
3463        // Send final input
3464        sender.send_final(json!({
3465            "display_description": "Create new file",
3466            "path": "root/dir/new_file.txt",
3467            "mode": "write",
3468            "content": "line 1\nline 2\nline 3\n"
3469        }));
3470
3471        let result = task.await;
3472        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3473            panic!("expected success");
3474        };
3475        assert_eq!(new_text, "line 1\nline 2\nline 3\n");
3476    }
3477
3478    #[gpui::test]
3479    async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) {
3480        let (tool, _project, _action_log, _fs, _thread) = setup_test(
3481            cx,
3482            json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3483        )
3484        .await;
3485        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3486        let (event_stream, mut receiver) = ToolCallEventStream::test();
3487        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3488
3489        // Transition to BufferResolved
3490        sender.send_partial(json!({
3491            "display_description": "Overwrite file",
3492            "path": "root/file.txt",
3493        }));
3494        cx.run_until_parked();
3495
3496        sender.send_partial(json!({
3497            "display_description": "Overwrite file",
3498            "path": "root/file.txt",
3499            "mode": "write"
3500        }));
3501        cx.run_until_parked();
3502
3503        // Get the diff entity from the event stream
3504        receiver.expect_update_fields().await;
3505        let diff = receiver.expect_diff().await;
3506
3507        // Diff starts pending with no revealed ranges
3508        diff.read_with(cx, |diff, cx| {
3509            assert!(matches!(diff, Diff::Pending(_)));
3510            assert!(!diff.has_revealed_range(cx));
3511        });
3512
3513        // Stream first content chunk
3514        sender.send_partial(json!({
3515            "display_description": "Overwrite file",
3516            "path": "root/file.txt",
3517            "mode": "write",
3518            "content": "new line 1\n"
3519        }));
3520        cx.run_until_parked();
3521
3522        // Diff should now have revealed ranges showing the new content
3523        diff.read_with(cx, |diff, cx| {
3524            assert!(diff.has_revealed_range(cx));
3525        });
3526
3527        // Send final input
3528        sender.send_final(json!({
3529            "display_description": "Overwrite file",
3530            "path": "root/file.txt",
3531            "mode": "write",
3532            "content": "new line 1\nnew line 2\n"
3533        }));
3534
3535        let result = task.await;
3536        let StreamingEditFileToolOutput::Success {
3537            new_text, old_text, ..
3538        } = result.unwrap()
3539        else {
3540            panic!("expected success");
3541        };
3542        assert_eq!(new_text, "new line 1\nnew line 2\n");
3543        assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3544
3545        // Diff is finalized after completion
3546        diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3547    }
3548
3549    #[gpui::test]
3550    async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) {
3551        let (tool, project, _action_log, _fs, _thread) = setup_test(
3552            cx,
3553            json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3554        )
3555        .await;
3556        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3557        let (event_stream, _receiver) = ToolCallEventStream::test();
3558        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3559
3560        // Transition to BufferResolved
3561        sender.send_partial(json!({
3562            "display_description": "Overwrite file",
3563            "path": "root/file.txt",
3564            "mode": "write"
3565        }));
3566        cx.run_until_parked();
3567
3568        // Verify buffer still has old content (no content partial yet)
3569        let buffer = project.update(cx, |project, cx| {
3570            let path = project.find_project_path("root/file.txt", cx).unwrap();
3571            project.open_buffer(path, cx)
3572        });
3573        let buffer = buffer.await.unwrap();
3574        assert_eq!(
3575            buffer.read_with(cx, |b, _| b.text()),
3576            "old line 1\nold line 2\nold line 3\n"
3577        );
3578
3579        // First content partial replaces old content
3580        sender.send_partial(json!({
3581            "display_description": "Overwrite file",
3582            "path": "root/file.txt",
3583            "mode": "write",
3584            "content": "new line 1\n"
3585        }));
3586        cx.run_until_parked();
3587        assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n");
3588
3589        // Subsequent content partials append
3590        sender.send_partial(json!({
3591            "display_description": "Overwrite file",
3592            "path": "root/file.txt",
3593            "mode": "write",
3594            "content": "new line 1\nnew line 2\n"
3595        }));
3596        cx.run_until_parked();
3597        assert_eq!(
3598            buffer.read_with(cx, |b, _| b.text()),
3599            "new line 1\nnew line 2\n"
3600        );
3601
3602        // Send final input with complete content
3603        sender.send_final(json!({
3604            "display_description": "Overwrite file",
3605            "path": "root/file.txt",
3606            "mode": "write",
3607            "content": "new line 1\nnew line 2\nnew line 3\n"
3608        }));
3609
3610        let result = task.await;
3611        let StreamingEditFileToolOutput::Success {
3612            new_text, old_text, ..
3613        } = result.unwrap()
3614        else {
3615            panic!("expected success");
3616        };
3617        assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n");
3618        assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3619    }
3620
3621    #[gpui::test]
3622    async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) {
3623        let (tool, _project, _action_log, _fs, _thread) =
3624            setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await;
3625        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3626        let (event_stream, _receiver) = ToolCallEventStream::test();
3627        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3628
3629        sender.send_partial(json!({
3630            "display_description": "Edit",
3631            "path": "root/file.txt",
3632            "mode": "edit"
3633        }));
3634        cx.run_until_parked();
3635
3636        // Simulate JSON fixer producing a literal backslash when the LLM
3637        // stream cuts in the middle of a \n escape sequence.
3638        // The old_text "hello\nworld" would be streamed as:
3639        //   partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\)
3640        //   partial 2: old_text = "hello\nworld" (fixer corrected the escape)
3641        sender.send_partial(json!({
3642            "display_description": "Edit",
3643            "path": "root/file.txt",
3644            "mode": "edit",
3645            "edits": [{"old_text": "hello\\"}]
3646        }));
3647        cx.run_until_parked();
3648
3649        // Now the fixer corrects it to the real newline.
3650        sender.send_partial(json!({
3651            "display_description": "Edit",
3652            "path": "root/file.txt",
3653            "mode": "edit",
3654            "edits": [{"old_text": "hello\nworld"}]
3655        }));
3656        cx.run_until_parked();
3657
3658        // Send final.
3659        sender.send_final(json!({
3660            "display_description": "Edit",
3661            "path": "root/file.txt",
3662            "mode": "edit",
3663            "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}]
3664        }));
3665
3666        let result = task.await;
3667        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3668            panic!("expected success");
3669        };
3670        assert_eq!(new_text, "HELLO\nWORLD\nfoo\n");
3671    }
3672
3673    // Verifies that after streaming_edit_file_tool edits a file, the action log
3674    // reports changed buffers so that the Accept All / Reject All review UI appears.
3675    #[gpui::test]
3676    async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) {
3677        let (tool, _project, action_log, _fs, _thread) =
3678            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
3679        cx.update(|cx| {
3680            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3681            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3682            agent_settings::AgentSettings::override_global(settings, cx);
3683        });
3684
3685        let (event_stream, _rx) = ToolCallEventStream::test();
3686        let task = cx.update(|cx| {
3687            tool.clone().run(
3688                ToolInput::resolved(StreamingEditFileToolInput {
3689                    display_description: "Edit lines".to_string(),
3690                    path: "root/file.txt".into(),
3691                    mode: StreamingEditFileMode::Edit,
3692                    content: None,
3693                    edits: Some(vec![Edit {
3694                        old_text: "line 2".into(),
3695                        new_text: "modified line 2".into(),
3696                    }]),
3697                }),
3698                event_stream,
3699                cx,
3700            )
3701        });
3702
3703        let result = task.await;
3704        assert!(result.is_ok(), "edit should succeed: {:?}", result.err());
3705
3706        cx.run_until_parked();
3707
3708        let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3709        assert!(
3710            !changed.is_empty(),
3711            "action_log.changed_buffers() should be non-empty after streaming edit,
3712             but no changed buffers were found - Accept All / Reject All will not appear"
3713        );
3714    }
3715
3716    // Same test but for Write mode (overwrite entire file).
3717    #[gpui::test]
3718    async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers(
3719        cx: &mut TestAppContext,
3720    ) {
3721        let (tool, _project, action_log, _fs, _thread) =
3722            setup_test(cx, json!({"file.txt": "original content"})).await;
3723        cx.update(|cx| {
3724            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3725            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3726            agent_settings::AgentSettings::override_global(settings, cx);
3727        });
3728
3729        let (event_stream, _rx) = ToolCallEventStream::test();
3730        let task = cx.update(|cx| {
3731            tool.clone().run(
3732                ToolInput::resolved(StreamingEditFileToolInput {
3733                    display_description: "Overwrite file".to_string(),
3734                    path: "root/file.txt".into(),
3735                    mode: StreamingEditFileMode::Write,
3736                    content: Some("completely new content".into()),
3737                    edits: None,
3738                }),
3739                event_stream,
3740                cx,
3741            )
3742        });
3743
3744        let result = task.await;
3745        assert!(result.is_ok(), "write should succeed: {:?}", result.err());
3746
3747        cx.run_until_parked();
3748
3749        let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3750        assert!(
3751            !changed.is_empty(),
3752            "action_log.changed_buffers() should be non-empty after streaming write, \
3753             but no changed buffers were found \u{2014} Accept All / Reject All will not appear"
3754        );
3755    }
3756
3757    #[gpui::test]
3758    async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
3759        cx: &mut TestAppContext,
3760    ) {
3761        let (tool, _project, _action_log, _fs, _thread) =
3762            setup_test(cx, json!({"file.txt": "old_content"})).await;
3763        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3764        let (event_stream, _receiver) = ToolCallEventStream::test();
3765        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3766
3767        sender.send_partial(json!({
3768            "display_description": "Overwrite file",
3769            "mode": "write"
3770        }));
3771        cx.run_until_parked();
3772
3773        sender.send_partial(json!({
3774            "display_description": "Overwrite file",
3775            "mode": "write",
3776            "content": "new_content"
3777        }));
3778        cx.run_until_parked();
3779
3780        sender.send_partial(json!({
3781            "display_description": "Overwrite file",
3782            "mode": "write",
3783            "content": "new_content",
3784            "path": "root"
3785        }));
3786        cx.run_until_parked();
3787
3788        // Send final.
3789        sender.send_final(json!({
3790            "display_description": "Overwrite file",
3791            "mode": "write",
3792            "content": "new_content",
3793            "path": "root/file.txt"
3794        }));
3795
3796        let result = task.await;
3797        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3798            panic!("expected success");
3799        };
3800        assert_eq!(new_text, "new_content");
3801    }
3802
3803    #[gpui::test]
3804    async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
3805        cx: &mut TestAppContext,
3806    ) {
3807        let (tool, _project, _action_log, _fs, _thread) =
3808            setup_test(cx, json!({"file.txt": "old_content"})).await;
3809        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3810        let (event_stream, _receiver) = ToolCallEventStream::test();
3811        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3812
3813        sender.send_partial(json!({
3814            "display_description": "Overwrite file",
3815            "mode": "edit"
3816        }));
3817        cx.run_until_parked();
3818
3819        sender.send_partial(json!({
3820            "display_description": "Overwrite file",
3821            "mode": "edit",
3822            "edits": [{"old_text": "old_content"}]
3823        }));
3824        cx.run_until_parked();
3825
3826        sender.send_partial(json!({
3827            "display_description": "Overwrite file",
3828            "mode": "edit",
3829            "edits": [{"old_text": "old_content", "new_text": "new_content"}]
3830        }));
3831        cx.run_until_parked();
3832
3833        sender.send_partial(json!({
3834            "display_description": "Overwrite file",
3835            "mode": "edit",
3836            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
3837            "path": "root"
3838        }));
3839        cx.run_until_parked();
3840
3841        // Send final.
3842        sender.send_final(json!({
3843            "display_description": "Overwrite file",
3844            "mode": "edit",
3845            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
3846            "path": "root/file.txt"
3847        }));
3848        cx.run_until_parked();
3849
3850        let result = task.await;
3851        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3852            panic!("expected success");
3853        };
3854        assert_eq!(new_text, "new_content");
3855    }
3856
3857    #[gpui::test]
3858    async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) {
3859        let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
3860        cx.update(|cx| {
3861            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3862            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3863            agent_settings::AgentSettings::override_global(settings, cx);
3864        });
3865
3866        // Create a new file via the streaming edit file tool
3867        let (event_stream, _rx) = ToolCallEventStream::test();
3868        let task = cx.update(|cx| {
3869            tool.clone().run(
3870                ToolInput::resolved(StreamingEditFileToolInput {
3871                    display_description: "Create new file".into(),
3872                    path: "root/dir/new_file.txt".into(),
3873                    mode: StreamingEditFileMode::Write,
3874                    content: Some("Hello, World!".into()),
3875                    edits: None,
3876                }),
3877                event_stream,
3878                cx,
3879            )
3880        });
3881        let result = task.await;
3882        assert!(result.is_ok(), "create should succeed: {:?}", result.err());
3883        cx.run_until_parked();
3884
3885        assert!(
3886            fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
3887            "file should exist after creation"
3888        );
3889
3890        // Reject all edits — this should delete the newly created file
3891        let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3892        assert!(
3893            !changed.is_empty(),
3894            "action_log should track the created file as changed"
3895        );
3896
3897        action_log
3898            .update(cx, |log, cx| log.reject_all_edits(None, cx))
3899            .await;
3900        cx.run_until_parked();
3901
3902        assert!(
3903            !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
3904            "file should be deleted after rejecting creation, but an empty file was left behind"
3905        );
3906    }
3907
3908    async fn setup_test_with_fs(
3909        cx: &mut TestAppContext,
3910        fs: Arc<project::FakeFs>,
3911        worktree_paths: &[&std::path::Path],
3912    ) -> (
3913        Arc<StreamingEditFileTool>,
3914        Entity<Project>,
3915        Entity<ActionLog>,
3916        Arc<project::FakeFs>,
3917        Entity<Thread>,
3918    ) {
3919        let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await;
3920        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3921        let context_server_registry =
3922            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3923        let model = Arc::new(FakeLanguageModel::default());
3924        let thread = cx.new(|cx| {
3925            crate::Thread::new(
3926                project.clone(),
3927                cx.new(|_cx| ProjectContext::default()),
3928                context_server_registry,
3929                Templates::new(),
3930                Some(model),
3931                cx,
3932            )
3933        });
3934        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
3935        let tool = Arc::new(StreamingEditFileTool::new(
3936            project.clone(),
3937            thread.downgrade(),
3938            action_log.clone(),
3939            language_registry,
3940        ));
3941        (tool, project, action_log, fs, thread)
3942    }
3943
3944    async fn setup_test(
3945        cx: &mut TestAppContext,
3946        initial_tree: serde_json::Value,
3947    ) -> (
3948        Arc<StreamingEditFileTool>,
3949        Entity<Project>,
3950        Entity<ActionLog>,
3951        Arc<project::FakeFs>,
3952        Entity<Thread>,
3953    ) {
3954        init_test(cx);
3955        let fs = project::FakeFs::new(cx.executor());
3956        fs.insert_tree("/root", initial_tree).await;
3957        setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await
3958    }
3959
3960    fn init_test(cx: &mut TestAppContext) {
3961        cx.update(|cx| {
3962            let settings_store = SettingsStore::test(cx);
3963            cx.set_global(settings_store);
3964            SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
3965                store.update_user_settings(cx, |settings| {
3966                    settings
3967                        .project
3968                        .all_languages
3969                        .defaults
3970                        .ensure_final_newline_on_save = Some(false);
3971                });
3972            });
3973        });
3974    }
3975}