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