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