edit_file_tool.rs

   1use crate::{
   2    AgentTool, Templates, Thread, ToolCallEventStream, ToolPermissionDecision,
   3    decide_permission_from_settings,
   4    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
   5};
   6use acp_thread::Diff;
   7use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
   8use anyhow::{Context as _, Result, anyhow};
   9use cloud_llm_client::CompletionIntent;
  10use collections::HashSet;
  11use futures::{FutureExt as _, StreamExt as _};
  12use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
  13use indoc::formatdoc;
  14use language::language_settings::{self, FormatOnSave};
  15use language::{LanguageRegistry, ToPoint};
  16use language_model::LanguageModelToolResultContent;
  17use paths;
  18use project::lsp_store::{FormatTrigger, LspFormatTarget};
  19use project::{Project, ProjectPath};
  20use schemars::JsonSchema;
  21use serde::{Deserialize, Serialize};
  22use settings::Settings;
  23use std::ffi::OsStr;
  24use std::path::{Path, PathBuf};
  25use std::sync::Arc;
  26use ui::SharedString;
  27use util::ResultExt;
  28use util::rel_path::RelPath;
  29
  30const DEFAULT_UI_TEXT: &str = "Editing file";
  31
  32/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
  33///
  34/// Before using this tool:
  35///
  36/// 1. Use the `read_file` tool to understand the file's contents and context
  37///
  38/// 2. Verify the directory path is correct (only applicable when creating new files):
  39///    - Use the `list_directory` tool to verify the parent directory exists and is the correct location
  40#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  41pub struct EditFileToolInput {
  42    /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.
  43    ///
  44    /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
  45    ///
  46    /// NEVER mention the file path in this description.
  47    ///
  48    /// <example>Fix API endpoint URLs</example>
  49    /// <example>Update copyright year in `page_footer`</example>
  50    ///
  51    /// Make sure to include this field before all the others in the input object so that we can display it immediately.
  52    pub display_description: String,
  53
  54    /// The full path of the file to create or modify in the project.
  55    ///
  56    /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
  57    ///
  58    /// The following examples assume we have two root directories in the project:
  59    /// - /a/b/backend
  60    /// - /c/d/frontend
  61    ///
  62    /// <example>
  63    /// `backend/src/main.rs`
  64    ///
  65    /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
  66    /// </example>
  67    ///
  68    /// <example>
  69    /// `frontend/db.js`
  70    /// </example>
  71    pub path: PathBuf,
  72    /// The mode of operation on the file. Possible values:
  73    /// - 'edit': Make granular edits to an existing file.
  74    /// - 'create': Create a new file if it doesn't exist.
  75    /// - 'overwrite': Replace the entire contents of an existing file.
  76    ///
  77    /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
  78    pub mode: EditFileMode,
  79}
  80
  81#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  82struct EditFileToolPartialInput {
  83    #[serde(default)]
  84    path: String,
  85    #[serde(default)]
  86    display_description: String,
  87}
  88
  89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  90#[serde(rename_all = "lowercase")]
  91#[schemars(inline)]
  92pub enum EditFileMode {
  93    Edit,
  94    Create,
  95    Overwrite,
  96}
  97
  98#[derive(Debug, Serialize, Deserialize)]
  99pub struct EditFileToolOutput {
 100    #[serde(alias = "original_path")]
 101    input_path: PathBuf,
 102    new_text: String,
 103    old_text: Arc<String>,
 104    #[serde(default)]
 105    diff: String,
 106    #[serde(alias = "raw_output")]
 107    edit_agent_output: EditAgentOutput,
 108}
 109
 110impl From<EditFileToolOutput> for LanguageModelToolResultContent {
 111    fn from(output: EditFileToolOutput) -> Self {
 112        if output.diff.is_empty() {
 113            "No edits were made.".into()
 114        } else {
 115            format!(
 116                "Edited {}:\n\n```diff\n{}\n```",
 117                output.input_path.display(),
 118                output.diff
 119            )
 120            .into()
 121        }
 122    }
 123}
 124
 125pub struct EditFileTool {
 126    thread: WeakEntity<Thread>,
 127    language_registry: Arc<LanguageRegistry>,
 128    project: Entity<Project>,
 129    templates: Arc<Templates>,
 130}
 131
 132impl EditFileTool {
 133    pub fn new(
 134        project: Entity<Project>,
 135        thread: WeakEntity<Thread>,
 136        language_registry: Arc<LanguageRegistry>,
 137        templates: Arc<Templates>,
 138    ) -> Self {
 139        Self {
 140            project,
 141            thread,
 142            language_registry,
 143            templates,
 144        }
 145    }
 146
 147    pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
 148        Self {
 149            project: self.project.clone(),
 150            thread: new_thread,
 151            language_registry: self.language_registry.clone(),
 152            templates: self.templates.clone(),
 153        }
 154    }
 155
 156    fn authorize(
 157        &self,
 158        input: &EditFileToolInput,
 159        event_stream: &ToolCallEventStream,
 160        cx: &mut App,
 161    ) -> Task<Result<()>> {
 162        let path_str = input.path.to_string_lossy();
 163        let settings = agent_settings::AgentSettings::get_global(cx);
 164        let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
 165
 166        match decision {
 167            ToolPermissionDecision::Allow => return Task::ready(Ok(())),
 168            ToolPermissionDecision::Deny(reason) => {
 169                return Task::ready(Err(anyhow!("{}", reason)));
 170            }
 171            ToolPermissionDecision::Confirm => {}
 172        }
 173
 174        // If any path component matches the local settings folder, then this could affect
 175        // the editor in ways beyond the project source, so prompt.
 176        let local_settings_folder = paths::local_settings_folder_name();
 177        let path = Path::new(&input.path);
 178        if path.components().any(|component| {
 179            component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
 180        }) {
 181            let context = crate::ToolPermissionContext {
 182                tool_name: "edit_file".to_string(),
 183                input_value: path_str.to_string(),
 184            };
 185            return event_stream.authorize(
 186                format!("{} (local settings)", input.display_description),
 187                context,
 188                cx,
 189            );
 190        }
 191
 192        // It's also possible that the global config dir is configured to be inside the project,
 193        // so check for that edge case too.
 194        // TODO this is broken when remoting
 195        if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
 196            && canonical_path.starts_with(paths::config_dir())
 197        {
 198            let context = crate::ToolPermissionContext {
 199                tool_name: "edit_file".to_string(),
 200                input_value: path_str.to_string(),
 201            };
 202            return event_stream.authorize(
 203                format!("{} (global settings)", input.display_description),
 204                context,
 205                cx,
 206            );
 207        }
 208
 209        // Check if path is inside the global config directory
 210        // First check if it's already inside project - if not, try to canonicalize
 211        let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {
 212            thread.project().read(cx).find_project_path(&input.path, cx)
 213        }) else {
 214            return Task::ready(Err(anyhow!("thread was dropped")));
 215        };
 216
 217        // If the path is inside the project, and it's not one of the above edge cases,
 218        // then no confirmation is necessary. Otherwise, confirmation is necessary.
 219        if project_path.is_some() {
 220            Task::ready(Ok(()))
 221        } else {
 222            let context = crate::ToolPermissionContext {
 223                tool_name: "edit_file".to_string(),
 224                input_value: path_str.to_string(),
 225            };
 226            event_stream.authorize(&input.display_description, context, cx)
 227        }
 228    }
 229}
 230
 231impl AgentTool for EditFileTool {
 232    type Input = EditFileToolInput;
 233    type Output = EditFileToolOutput;
 234
 235    fn name() -> &'static str {
 236        "edit_file"
 237    }
 238
 239    fn kind() -> acp::ToolKind {
 240        acp::ToolKind::Edit
 241    }
 242
 243    fn initial_title(
 244        &self,
 245        input: Result<Self::Input, serde_json::Value>,
 246        cx: &mut App,
 247    ) -> SharedString {
 248        match input {
 249            Ok(input) => self
 250                .project
 251                .read(cx)
 252                .find_project_path(&input.path, cx)
 253                .and_then(|project_path| {
 254                    self.project
 255                        .read(cx)
 256                        .short_full_path_for_project_path(&project_path, cx)
 257                })
 258                .unwrap_or(input.path.to_string_lossy().into_owned())
 259                .into(),
 260            Err(raw_input) => {
 261                if let Some(input) =
 262                    serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
 263                {
 264                    let path = input.path.trim();
 265                    if !path.is_empty() {
 266                        return self
 267                            .project
 268                            .read(cx)
 269                            .find_project_path(&input.path, cx)
 270                            .and_then(|project_path| {
 271                                self.project
 272                                    .read(cx)
 273                                    .short_full_path_for_project_path(&project_path, cx)
 274                            })
 275                            .unwrap_or(input.path)
 276                            .into();
 277                    }
 278
 279                    let description = input.display_description.trim();
 280                    if !description.is_empty() {
 281                        return description.to_string().into();
 282                    }
 283                }
 284
 285                DEFAULT_UI_TEXT.into()
 286            }
 287        }
 288    }
 289
 290    fn run(
 291        self: Arc<Self>,
 292        input: Self::Input,
 293        event_stream: ToolCallEventStream,
 294        cx: &mut App,
 295    ) -> Task<Result<Self::Output>> {
 296        let Ok(project) = self
 297            .thread
 298            .read_with(cx, |thread, _cx| thread.project().clone())
 299        else {
 300            return Task::ready(Err(anyhow!("thread was dropped")));
 301        };
 302        let project_path = match resolve_path(&input, project.clone(), cx) {
 303            Ok(path) => path,
 304            Err(err) => return Task::ready(Err(anyhow!(err))),
 305        };
 306        let abs_path = project.read(cx).absolute_path(&project_path, cx);
 307        if let Some(abs_path) = abs_path.clone() {
 308            event_stream.update_fields(
 309                ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
 310            );
 311        }
 312
 313        let authorize = self.authorize(&input, &event_stream, cx);
 314        cx.spawn(async move |cx: &mut AsyncApp| {
 315            authorize.await?;
 316
 317            let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
 318                let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
 319                (request, thread.model().cloned(), thread.action_log().clone())
 320            })?;
 321            let request = request?;
 322            let model = model.context("No language model configured")?;
 323
 324            let edit_format = EditFormat::from_model(model.clone())?;
 325            let edit_agent = EditAgent::new(
 326                model,
 327                project.clone(),
 328                action_log.clone(),
 329                self.templates.clone(),
 330                edit_format,
 331            );
 332
 333            let buffer = project
 334                .update(cx, |project, cx| {
 335                    project.open_buffer(project_path.clone(), cx)
 336                })
 337                .await?;
 338
 339            // Check if the file has been modified since the agent last read it
 340            if let Some(abs_path) = abs_path.as_ref() {
 341                let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
 342                    let last_read = thread.file_read_times.get(abs_path).copied();
 343                    let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
 344                    let dirty = buffer.read(cx).is_dirty();
 345                    let has_save = thread.has_tool("save_file");
 346                    let has_restore = thread.has_tool("restore_file_from_disk");
 347                    (last_read, current, dirty, has_save, has_restore)
 348                })?;
 349
 350                // Check for unsaved changes first - these indicate modifications we don't know about
 351                if is_dirty {
 352                    let message = match (has_save_tool, has_restore_tool) {
 353                        (true, true) => {
 354                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 355                             If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 356                             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."
 357                        }
 358                        (true, false) => {
 359                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 360                             If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 361                             If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
 362                        }
 363                        (false, true) => {
 364                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 365                             If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
 366                             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."
 367                        }
 368                        (false, false) => {
 369                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
 370                             then ask them to save or revert the file manually and inform you when it's ok to proceed."
 371                        }
 372                    };
 373                    anyhow::bail!("{}", message);
 374                }
 375
 376                // Check if the file was modified on disk since we last read it
 377                if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
 378                    // MTime can be unreliable for comparisons, so our newtype intentionally
 379                    // doesn't support comparing them. If the mtime at all different
 380                    // (which could be because of a modification or because e.g. system clock changed),
 381                    // we pessimistically assume it was modified.
 382                    if current != last_read {
 383                        anyhow::bail!(
 384                            "The file {} has been modified since you last read it. \
 385                             Please read the file again to get the current state before editing it.",
 386                            input.path.display()
 387                        );
 388                    }
 389                }
 390            }
 391
 392            let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
 393            event_stream.update_diff(diff.clone());
 394            let _finalize_diff = util::defer({
 395               let diff = diff.downgrade();
 396               let mut cx = cx.clone();
 397               move || {
 398                   diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
 399               }
 400            });
 401
 402            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 403            let old_text = cx
 404                .background_spawn({
 405                    let old_snapshot = old_snapshot.clone();
 406                    async move { Arc::new(old_snapshot.text()) }
 407                })
 408                .await;
 409
 410            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 411                edit_agent.edit(
 412                    buffer.clone(),
 413                    input.display_description.clone(),
 414                    &request,
 415                    cx,
 416                )
 417            } else {
 418                edit_agent.overwrite(
 419                    buffer.clone(),
 420                    input.display_description.clone(),
 421                    &request,
 422                    cx,
 423                )
 424            };
 425
 426            let mut hallucinated_old_text = false;
 427            let mut ambiguous_ranges = Vec::new();
 428            let mut emitted_location = false;
 429            loop {
 430                let event = futures::select! {
 431                    event = events.next().fuse() => match event {
 432                        Some(event) => event,
 433                        None => break,
 434                    },
 435                    _ = event_stream.cancelled_by_user().fuse() => {
 436                        anyhow::bail!("Edit cancelled by user");
 437                    }
 438                };
 439                match event {
 440                    EditAgentOutputEvent::Edited(range) => {
 441                        if !emitted_location {
 442                            let line = Some(buffer.update(cx, |buffer, _cx| {
 443                                range.start.to_point(&buffer.snapshot()).row
 444                            }));
 445                            if let Some(abs_path) = abs_path.clone() {
 446                                event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
 447                            }
 448                            emitted_location = true;
 449                        }
 450                    },
 451                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
 452                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
 453                    EditAgentOutputEvent::ResolvingEditRange(range) => {
 454                        diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
 455                        // if !emitted_location {
 456                        //     let line = buffer.update(cx, |buffer, _cx| {
 457                        //         range.start.to_point(&buffer.snapshot()).row
 458                        //     }).ok();
 459                        //     if let Some(abs_path) = abs_path.clone() {
 460                        //         event_stream.update_fields(ToolCallUpdateFields {
 461                        //             locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
 462                        //             ..Default::default()
 463                        //         });
 464                        //     }
 465                        // }
 466                    }
 467                }
 468            }
 469
 470            // If format_on_save is enabled, format the buffer
 471            let format_on_save_enabled = buffer
 472                .read_with(cx, |buffer, cx| {
 473                    let settings = language_settings::LanguageSettings::for_buffer(buffer, cx);
 474                    settings.format_on_save != FormatOnSave::Off
 475                });
 476
 477            let edit_agent_output = output.await?;
 478
 479            if format_on_save_enabled {
 480                action_log.update(cx, |log, cx| {
 481                    log.buffer_edited(buffer.clone(), cx);
 482                });
 483
 484                let format_task = project.update(cx, |project, cx| {
 485                    project.format(
 486                        HashSet::from_iter([buffer.clone()]),
 487                        LspFormatTarget::Buffers,
 488                        false, // Don't push to history since the tool did it.
 489                        FormatTrigger::Save,
 490                        cx,
 491                    )
 492                });
 493                format_task.await.log_err();
 494            }
 495
 496            project
 497                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
 498                .await?;
 499
 500            action_log.update(cx, |log, cx| {
 501                log.buffer_edited(buffer.clone(), cx);
 502            });
 503
 504            // Update the recorded read time after a successful edit so consecutive edits work
 505            if let Some(abs_path) = abs_path.as_ref() {
 506                if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
 507                    buffer.file().and_then(|file| file.disk_state().mtime())
 508                }) {
 509                    self.thread.update(cx, |thread, _| {
 510                        thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
 511                    })?;
 512                }
 513            }
 514
 515            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 516            let (new_text, unified_diff) = cx
 517                .background_spawn({
 518                    let new_snapshot = new_snapshot.clone();
 519                    let old_text = old_text.clone();
 520                    async move {
 521                        let new_text = new_snapshot.text();
 522                        let diff = language::unified_diff(&old_text, &new_text);
 523                        (new_text, diff)
 524                    }
 525                })
 526                .await;
 527
 528            let input_path = input.path.display();
 529            if unified_diff.is_empty() {
 530                anyhow::ensure!(
 531                    !hallucinated_old_text,
 532                    formatdoc! {"
 533                        Some edits were produced but none of them could be applied.
 534                        Read the relevant sections of {input_path} again so that
 535                        I can perform the requested edits.
 536                    "}
 537                );
 538                anyhow::ensure!(
 539                    ambiguous_ranges.is_empty(),
 540                    {
 541                        let line_numbers = ambiguous_ranges
 542                            .iter()
 543                            .map(|range| range.start.to_string())
 544                            .collect::<Vec<_>>()
 545                            .join(", ");
 546                        formatdoc! {"
 547                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
 548                            relevant sections of {input_path} again and extend <old_text> so
 549                            that I can perform the requested edits.
 550                        "}
 551                    }
 552                );
 553            }
 554
 555            Ok(EditFileToolOutput {
 556                input_path: input.path,
 557                new_text,
 558                old_text,
 559                diff: unified_diff,
 560                edit_agent_output,
 561            })
 562        })
 563    }
 564
 565    fn replay(
 566        &self,
 567        _input: Self::Input,
 568        output: Self::Output,
 569        event_stream: ToolCallEventStream,
 570        cx: &mut App,
 571    ) -> Result<()> {
 572        event_stream.update_diff(cx.new(|cx| {
 573            Diff::finalized(
 574                output.input_path.to_string_lossy().into_owned(),
 575                Some(output.old_text.to_string()),
 576                output.new_text,
 577                self.language_registry.clone(),
 578                cx,
 579            )
 580        }));
 581        Ok(())
 582    }
 583
 584    fn rebind_thread(
 585        &self,
 586        new_thread: gpui::WeakEntity<crate::Thread>,
 587    ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
 588        Some(self.with_thread(new_thread).erase())
 589    }
 590}
 591
 592/// Validate that the file path is valid, meaning:
 593///
 594/// - For `edit` and `overwrite`, the path must point to an existing file.
 595/// - For `create`, the file must not already exist, but it's parent dir must exist.
 596fn resolve_path(
 597    input: &EditFileToolInput,
 598    project: Entity<Project>,
 599    cx: &mut App,
 600) -> Result<ProjectPath> {
 601    let project = project.read(cx);
 602
 603    match input.mode {
 604        EditFileMode::Edit | EditFileMode::Overwrite => {
 605            let path = project
 606                .find_project_path(&input.path, cx)
 607                .context("Can't edit file: path not found")?;
 608
 609            let entry = project
 610                .entry_for_path(&path, cx)
 611                .context("Can't edit file: path not found")?;
 612
 613            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 614            Ok(path)
 615        }
 616
 617        EditFileMode::Create => {
 618            if let Some(path) = project.find_project_path(&input.path, cx) {
 619                anyhow::ensure!(
 620                    project.entry_for_path(&path, cx).is_none(),
 621                    "Can't create file: file already exists"
 622                );
 623            }
 624
 625            let parent_path = input
 626                .path
 627                .parent()
 628                .context("Can't create file: incorrect path")?;
 629
 630            let parent_project_path = project.find_project_path(&parent_path, cx);
 631
 632            let parent_entry = parent_project_path
 633                .as_ref()
 634                .and_then(|path| project.entry_for_path(path, cx))
 635                .context("Can't create file: parent directory doesn't exist")?;
 636
 637            anyhow::ensure!(
 638                parent_entry.is_dir(),
 639                "Can't create file: parent is not a directory"
 640            );
 641
 642            let file_name = input
 643                .path
 644                .file_name()
 645                .and_then(|file_name| file_name.to_str())
 646                .and_then(|file_name| RelPath::unix(file_name).ok())
 647                .context("Can't create file: invalid filename")?;
 648
 649            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 650                path: parent.path.join(file_name),
 651                ..parent
 652            });
 653
 654            new_file_path.context("Can't create file")
 655        }
 656    }
 657}
 658
 659#[cfg(test)]
 660mod tests {
 661    use super::*;
 662    use crate::{ContextServerRegistry, Templates};
 663    use fs::Fs;
 664    use gpui::{TestAppContext, UpdateGlobal};
 665    use language_model::fake_provider::FakeLanguageModel;
 666    use prompt_store::ProjectContext;
 667    use serde_json::json;
 668    use settings::SettingsStore;
 669    use util::{path, rel_path::rel_path};
 670
 671    #[gpui::test]
 672    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 673        init_test(cx);
 674
 675        let fs = project::FakeFs::new(cx.executor());
 676        fs.insert_tree("/root", json!({})).await;
 677        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 678        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
 679        let context_server_registry =
 680            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 681        let model = Arc::new(FakeLanguageModel::default());
 682        let thread = cx.new(|cx| {
 683            Thread::new(
 684                project.clone(),
 685                cx.new(|_cx| ProjectContext::default()),
 686                context_server_registry,
 687                Templates::new(),
 688                Some(model),
 689                cx,
 690            )
 691        });
 692        let result = cx
 693            .update(|cx| {
 694                let input = EditFileToolInput {
 695                    display_description: "Some edit".into(),
 696                    path: "root/nonexistent_file.txt".into(),
 697                    mode: EditFileMode::Edit,
 698                };
 699                Arc::new(EditFileTool::new(
 700                    project,
 701                    thread.downgrade(),
 702                    language_registry,
 703                    Templates::new(),
 704                ))
 705                .run(input, ToolCallEventStream::test().0, cx)
 706            })
 707            .await;
 708        assert_eq!(
 709            result.unwrap_err().to_string(),
 710            "Can't edit file: path not found"
 711        );
 712    }
 713
 714    #[gpui::test]
 715    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
 716        let mode = &EditFileMode::Create;
 717
 718        let result = test_resolve_path(mode, "root/new.txt", cx);
 719        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 720
 721        let result = test_resolve_path(mode, "new.txt", cx);
 722        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 723
 724        let result = test_resolve_path(mode, "dir/new.txt", cx);
 725        assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
 726
 727        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
 728        assert_eq!(
 729            result.await.unwrap_err().to_string(),
 730            "Can't create file: file already exists"
 731        );
 732
 733        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
 734        assert_eq!(
 735            result.await.unwrap_err().to_string(),
 736            "Can't create file: parent directory doesn't exist"
 737        );
 738    }
 739
 740    #[gpui::test]
 741    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
 742        let mode = &EditFileMode::Edit;
 743
 744        let path_with_root = "root/dir/subdir/existing.txt";
 745        let path_without_root = "dir/subdir/existing.txt";
 746        let result = test_resolve_path(mode, path_with_root, cx);
 747        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 748
 749        let result = test_resolve_path(mode, path_without_root, cx);
 750        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 751
 752        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
 753        assert_eq!(
 754            result.await.unwrap_err().to_string(),
 755            "Can't edit file: path not found"
 756        );
 757
 758        let result = test_resolve_path(mode, "root/dir", cx);
 759        assert_eq!(
 760            result.await.unwrap_err().to_string(),
 761            "Can't edit file: path is a directory"
 762        );
 763    }
 764
 765    async fn test_resolve_path(
 766        mode: &EditFileMode,
 767        path: &str,
 768        cx: &mut TestAppContext,
 769    ) -> anyhow::Result<ProjectPath> {
 770        init_test(cx);
 771
 772        let fs = project::FakeFs::new(cx.executor());
 773        fs.insert_tree(
 774            "/root",
 775            json!({
 776                "dir": {
 777                    "subdir": {
 778                        "existing.txt": "hello"
 779                    }
 780                }
 781            }),
 782        )
 783        .await;
 784        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 785
 786        let input = EditFileToolInput {
 787            display_description: "Some edit".into(),
 788            path: path.into(),
 789            mode: mode.clone(),
 790        };
 791
 792        cx.update(|cx| resolve_path(&input, project, cx))
 793    }
 794
 795    #[track_caller]
 796    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
 797        let actual = path.expect("Should return valid path").path;
 798        assert_eq!(actual.as_ref(), expected);
 799    }
 800
 801    #[gpui::test]
 802    async fn test_format_on_save(cx: &mut TestAppContext) {
 803        init_test(cx);
 804
 805        let fs = project::FakeFs::new(cx.executor());
 806        fs.insert_tree("/root", json!({"src": {}})).await;
 807
 808        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 809
 810        // Set up a Rust language with LSP formatting support
 811        let rust_language = Arc::new(language::Language::new(
 812            language::LanguageConfig {
 813                name: "Rust".into(),
 814                matcher: language::LanguageMatcher {
 815                    path_suffixes: vec!["rs".to_string()],
 816                    ..Default::default()
 817                },
 818                ..Default::default()
 819            },
 820            None,
 821        ));
 822
 823        // Register the language and fake LSP
 824        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 825        language_registry.add(rust_language);
 826
 827        let mut fake_language_servers = language_registry.register_fake_lsp(
 828            "Rust",
 829            language::FakeLspAdapter {
 830                capabilities: lsp::ServerCapabilities {
 831                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
 832                    ..Default::default()
 833                },
 834                ..Default::default()
 835            },
 836        );
 837
 838        // Create the file
 839        fs.save(
 840            path!("/root/src/main.rs").as_ref(),
 841            &"initial content".into(),
 842            language::LineEnding::Unix,
 843        )
 844        .await
 845        .unwrap();
 846
 847        // Open the buffer to trigger LSP initialization
 848        let buffer = project
 849            .update(cx, |project, cx| {
 850                project.open_local_buffer(path!("/root/src/main.rs"), cx)
 851            })
 852            .await
 853            .unwrap();
 854
 855        // Register the buffer with language servers
 856        let _handle = project.update(cx, |project, cx| {
 857            project.register_buffer_with_language_servers(&buffer, cx)
 858        });
 859
 860        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
 861        const FORMATTED_CONTENT: &str =
 862            "This file was formatted by the fake formatter in the test.\n";
 863
 864        // Get the fake language server and set up formatting handler
 865        let fake_language_server = fake_language_servers.next().await.unwrap();
 866        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
 867            |_, _| async move {
 868                Ok(Some(vec![lsp::TextEdit {
 869                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
 870                    new_text: FORMATTED_CONTENT.to_string(),
 871                }]))
 872            }
 873        });
 874
 875        let context_server_registry =
 876            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 877        let model = Arc::new(FakeLanguageModel::default());
 878        let thread = cx.new(|cx| {
 879            Thread::new(
 880                project.clone(),
 881                cx.new(|_cx| ProjectContext::default()),
 882                context_server_registry,
 883                Templates::new(),
 884                Some(model.clone()),
 885                cx,
 886            )
 887        });
 888
 889        // First, test with format_on_save enabled
 890        cx.update(|cx| {
 891            SettingsStore::update_global(cx, |store, cx| {
 892                store.update_user_settings(cx, |settings| {
 893                    settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
 894                    settings.project.all_languages.defaults.formatter =
 895                        Some(language::language_settings::FormatterList::default());
 896                });
 897            });
 898        });
 899
 900        // Have the model stream unformatted content
 901        let edit_result = {
 902            let edit_task = cx.update(|cx| {
 903                let input = EditFileToolInput {
 904                    display_description: "Create main function".into(),
 905                    path: "root/src/main.rs".into(),
 906                    mode: EditFileMode::Overwrite,
 907                };
 908                Arc::new(EditFileTool::new(
 909                    project.clone(),
 910                    thread.downgrade(),
 911                    language_registry.clone(),
 912                    Templates::new(),
 913                ))
 914                .run(input, ToolCallEventStream::test().0, cx)
 915            });
 916
 917            // Stream the unformatted content
 918            cx.executor().run_until_parked();
 919            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 920            model.end_last_completion_stream();
 921
 922            edit_task.await
 923        };
 924        assert!(edit_result.is_ok());
 925
 926        // Wait for any async operations (e.g. formatting) to complete
 927        cx.executor().run_until_parked();
 928
 929        // Read the file to verify it was formatted automatically
 930        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 931        assert_eq!(
 932            // Ignore carriage returns on Windows
 933            new_content.replace("\r\n", "\n"),
 934            FORMATTED_CONTENT,
 935            "Code should be formatted when format_on_save is enabled"
 936        );
 937
 938        let stale_buffer_count = thread
 939            .read_with(cx, |thread, _cx| thread.action_log.clone())
 940            .read_with(cx, |log, cx| log.stale_buffers(cx).count());
 941
 942        assert_eq!(
 943            stale_buffer_count, 0,
 944            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
 945             This causes the agent to think the file was modified externally when it was just formatted.",
 946            stale_buffer_count
 947        );
 948
 949        // Next, test with format_on_save disabled
 950        cx.update(|cx| {
 951            SettingsStore::update_global(cx, |store, cx| {
 952                store.update_user_settings(cx, |settings| {
 953                    settings.project.all_languages.defaults.format_on_save =
 954                        Some(FormatOnSave::Off);
 955                });
 956            });
 957        });
 958
 959        // Stream unformatted edits again
 960        let edit_result = {
 961            let edit_task = cx.update(|cx| {
 962                let input = EditFileToolInput {
 963                    display_description: "Update main function".into(),
 964                    path: "root/src/main.rs".into(),
 965                    mode: EditFileMode::Overwrite,
 966                };
 967                Arc::new(EditFileTool::new(
 968                    project.clone(),
 969                    thread.downgrade(),
 970                    language_registry,
 971                    Templates::new(),
 972                ))
 973                .run(input, ToolCallEventStream::test().0, cx)
 974            });
 975
 976            // Stream the unformatted content
 977            cx.executor().run_until_parked();
 978            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 979            model.end_last_completion_stream();
 980
 981            edit_task.await
 982        };
 983        assert!(edit_result.is_ok());
 984
 985        // Wait for any async operations (e.g. formatting) to complete
 986        cx.executor().run_until_parked();
 987
 988        // Verify the file was not formatted
 989        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 990        assert_eq!(
 991            // Ignore carriage returns on Windows
 992            new_content.replace("\r\n", "\n"),
 993            UNFORMATTED_CONTENT,
 994            "Code should not be formatted when format_on_save is disabled"
 995        );
 996    }
 997
 998    #[gpui::test]
 999    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1000        init_test(cx);
1001
1002        let fs = project::FakeFs::new(cx.executor());
1003        fs.insert_tree("/root", json!({"src": {}})).await;
1004
1005        // Create a simple file with trailing whitespace
1006        fs.save(
1007            path!("/root/src/main.rs").as_ref(),
1008            &"initial content".into(),
1009            language::LineEnding::Unix,
1010        )
1011        .await
1012        .unwrap();
1013
1014        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1015        let context_server_registry =
1016            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1017        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1018        let model = Arc::new(FakeLanguageModel::default());
1019        let thread = cx.new(|cx| {
1020            Thread::new(
1021                project.clone(),
1022                cx.new(|_cx| ProjectContext::default()),
1023                context_server_registry,
1024                Templates::new(),
1025                Some(model.clone()),
1026                cx,
1027            )
1028        });
1029
1030        // First, test with remove_trailing_whitespace_on_save enabled
1031        cx.update(|cx| {
1032            SettingsStore::update_global(cx, |store, cx| {
1033                store.update_user_settings(cx, |settings| {
1034                    settings
1035                        .project
1036                        .all_languages
1037                        .defaults
1038                        .remove_trailing_whitespace_on_save = Some(true);
1039                });
1040            });
1041        });
1042
1043        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1044            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
1045
1046        // Have the model stream content that contains trailing whitespace
1047        let edit_result = {
1048            let edit_task = cx.update(|cx| {
1049                let input = EditFileToolInput {
1050                    display_description: "Create main function".into(),
1051                    path: "root/src/main.rs".into(),
1052                    mode: EditFileMode::Overwrite,
1053                };
1054                Arc::new(EditFileTool::new(
1055                    project.clone(),
1056                    thread.downgrade(),
1057                    language_registry.clone(),
1058                    Templates::new(),
1059                ))
1060                .run(input, ToolCallEventStream::test().0, cx)
1061            });
1062
1063            // Stream the content with trailing whitespace
1064            cx.executor().run_until_parked();
1065            model.send_last_completion_stream_text_chunk(
1066                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1067            );
1068            model.end_last_completion_stream();
1069
1070            edit_task.await
1071        };
1072        assert!(edit_result.is_ok());
1073
1074        // Wait for any async operations (e.g. formatting) to complete
1075        cx.executor().run_until_parked();
1076
1077        // Read the file to verify trailing whitespace was removed automatically
1078        assert_eq!(
1079            // Ignore carriage returns on Windows
1080            fs.load(path!("/root/src/main.rs").as_ref())
1081                .await
1082                .unwrap()
1083                .replace("\r\n", "\n"),
1084            "fn main() {\n    println!(\"Hello!\");\n}\n",
1085            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1086        );
1087
1088        // Next, test with remove_trailing_whitespace_on_save disabled
1089        cx.update(|cx| {
1090            SettingsStore::update_global(cx, |store, cx| {
1091                store.update_user_settings(cx, |settings| {
1092                    settings
1093                        .project
1094                        .all_languages
1095                        .defaults
1096                        .remove_trailing_whitespace_on_save = Some(false);
1097                });
1098            });
1099        });
1100
1101        // Stream edits again with trailing whitespace
1102        let edit_result = {
1103            let edit_task = cx.update(|cx| {
1104                let input = EditFileToolInput {
1105                    display_description: "Update main function".into(),
1106                    path: "root/src/main.rs".into(),
1107                    mode: EditFileMode::Overwrite,
1108                };
1109                Arc::new(EditFileTool::new(
1110                    project.clone(),
1111                    thread.downgrade(),
1112                    language_registry,
1113                    Templates::new(),
1114                ))
1115                .run(input, ToolCallEventStream::test().0, cx)
1116            });
1117
1118            // Stream the content with trailing whitespace
1119            cx.executor().run_until_parked();
1120            model.send_last_completion_stream_text_chunk(
1121                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1122            );
1123            model.end_last_completion_stream();
1124
1125            edit_task.await
1126        };
1127        assert!(edit_result.is_ok());
1128
1129        // Wait for any async operations (e.g. formatting) to complete
1130        cx.executor().run_until_parked();
1131
1132        // Verify the file still has trailing whitespace
1133        // Read the file again - it should still have trailing whitespace
1134        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1135        assert_eq!(
1136            // Ignore carriage returns on Windows
1137            final_content.replace("\r\n", "\n"),
1138            CONTENT_WITH_TRAILING_WHITESPACE,
1139            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1140        );
1141    }
1142
1143    #[gpui::test]
1144    async fn test_authorize(cx: &mut TestAppContext) {
1145        init_test(cx);
1146        let fs = project::FakeFs::new(cx.executor());
1147        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1148        let context_server_registry =
1149            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1150        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1151        let model = Arc::new(FakeLanguageModel::default());
1152        let thread = cx.new(|cx| {
1153            Thread::new(
1154                project.clone(),
1155                cx.new(|_cx| ProjectContext::default()),
1156                context_server_registry,
1157                Templates::new(),
1158                Some(model.clone()),
1159                cx,
1160            )
1161        });
1162        let tool = Arc::new(EditFileTool::new(
1163            project.clone(),
1164            thread.downgrade(),
1165            language_registry,
1166            Templates::new(),
1167        ));
1168        fs.insert_tree("/root", json!({})).await;
1169
1170        // Test 1: Path with .zed component should require confirmation
1171        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1172        let _auth = cx.update(|cx| {
1173            tool.authorize(
1174                &EditFileToolInput {
1175                    display_description: "test 1".into(),
1176                    path: ".zed/settings.json".into(),
1177                    mode: EditFileMode::Edit,
1178                },
1179                &stream_tx,
1180                cx,
1181            )
1182        });
1183
1184        let event = stream_rx.expect_authorization().await;
1185        assert_eq!(
1186            event.tool_call.fields.title,
1187            Some("test 1 (local settings)".into())
1188        );
1189
1190        // Test 2: Path outside project should require confirmation
1191        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1192        let _auth = cx.update(|cx| {
1193            tool.authorize(
1194                &EditFileToolInput {
1195                    display_description: "test 2".into(),
1196                    path: "/etc/hosts".into(),
1197                    mode: EditFileMode::Edit,
1198                },
1199                &stream_tx,
1200                cx,
1201            )
1202        });
1203
1204        let event = stream_rx.expect_authorization().await;
1205        assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1206
1207        // Test 3: Relative path without .zed should not require confirmation
1208        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1209        cx.update(|cx| {
1210            tool.authorize(
1211                &EditFileToolInput {
1212                    display_description: "test 3".into(),
1213                    path: "root/src/main.rs".into(),
1214                    mode: EditFileMode::Edit,
1215                },
1216                &stream_tx,
1217                cx,
1218            )
1219        })
1220        .await
1221        .unwrap();
1222        assert!(stream_rx.try_next().is_err());
1223
1224        // Test 4: Path with .zed in the middle should require confirmation
1225        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1226        let _auth = cx.update(|cx| {
1227            tool.authorize(
1228                &EditFileToolInput {
1229                    display_description: "test 4".into(),
1230                    path: "root/.zed/tasks.json".into(),
1231                    mode: EditFileMode::Edit,
1232                },
1233                &stream_tx,
1234                cx,
1235            )
1236        });
1237        let event = stream_rx.expect_authorization().await;
1238        assert_eq!(
1239            event.tool_call.fields.title,
1240            Some("test 4 (local settings)".into())
1241        );
1242
1243        // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1244        cx.update(|cx| {
1245            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1246            settings.always_allow_tool_actions = true;
1247            agent_settings::AgentSettings::override_global(settings, cx);
1248        });
1249
1250        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1251        cx.update(|cx| {
1252            tool.authorize(
1253                &EditFileToolInput {
1254                    display_description: "test 5.1".into(),
1255                    path: ".zed/settings.json".into(),
1256                    mode: EditFileMode::Edit,
1257                },
1258                &stream_tx,
1259                cx,
1260            )
1261        })
1262        .await
1263        .unwrap();
1264        assert!(stream_rx.try_next().is_err());
1265
1266        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1267        cx.update(|cx| {
1268            tool.authorize(
1269                &EditFileToolInput {
1270                    display_description: "test 5.2".into(),
1271                    path: "/etc/hosts".into(),
1272                    mode: EditFileMode::Edit,
1273                },
1274                &stream_tx,
1275                cx,
1276            )
1277        })
1278        .await
1279        .unwrap();
1280        assert!(stream_rx.try_next().is_err());
1281    }
1282
1283    #[gpui::test]
1284    async fn test_authorize_global_config(cx: &mut TestAppContext) {
1285        init_test(cx);
1286        let fs = project::FakeFs::new(cx.executor());
1287        fs.insert_tree("/project", json!({})).await;
1288        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1289        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1290        let context_server_registry =
1291            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1292        let model = Arc::new(FakeLanguageModel::default());
1293        let thread = cx.new(|cx| {
1294            Thread::new(
1295                project.clone(),
1296                cx.new(|_cx| ProjectContext::default()),
1297                context_server_registry,
1298                Templates::new(),
1299                Some(model.clone()),
1300                cx,
1301            )
1302        });
1303        let tool = Arc::new(EditFileTool::new(
1304            project.clone(),
1305            thread.downgrade(),
1306            language_registry,
1307            Templates::new(),
1308        ));
1309
1310        // Test global config paths - these should require confirmation if they exist and are outside the project
1311        let test_cases = vec![
1312            (
1313                "/etc/hosts",
1314                true,
1315                "System file should require confirmation",
1316            ),
1317            (
1318                "/usr/local/bin/script",
1319                true,
1320                "System bin file should require confirmation",
1321            ),
1322            (
1323                "project/normal_file.rs",
1324                false,
1325                "Normal project file should not require confirmation",
1326            ),
1327        ];
1328
1329        for (path, should_confirm, description) in test_cases {
1330            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1331            let auth = cx.update(|cx| {
1332                tool.authorize(
1333                    &EditFileToolInput {
1334                        display_description: "Edit file".into(),
1335                        path: path.into(),
1336                        mode: EditFileMode::Edit,
1337                    },
1338                    &stream_tx,
1339                    cx,
1340                )
1341            });
1342
1343            if should_confirm {
1344                stream_rx.expect_authorization().await;
1345            } else {
1346                auth.await.unwrap();
1347                assert!(
1348                    stream_rx.try_next().is_err(),
1349                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1350                    description,
1351                    path
1352                );
1353            }
1354        }
1355    }
1356
1357    #[gpui::test]
1358    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1359        init_test(cx);
1360        let fs = project::FakeFs::new(cx.executor());
1361
1362        // Create multiple worktree directories
1363        fs.insert_tree(
1364            "/workspace/frontend",
1365            json!({
1366                "src": {
1367                    "main.js": "console.log('frontend');"
1368                }
1369            }),
1370        )
1371        .await;
1372        fs.insert_tree(
1373            "/workspace/backend",
1374            json!({
1375                "src": {
1376                    "main.rs": "fn main() {}"
1377                }
1378            }),
1379        )
1380        .await;
1381        fs.insert_tree(
1382            "/workspace/shared",
1383            json!({
1384                ".zed": {
1385                    "settings.json": "{}"
1386                }
1387            }),
1388        )
1389        .await;
1390
1391        // Create project with multiple worktrees
1392        let project = Project::test(
1393            fs.clone(),
1394            [
1395                path!("/workspace/frontend").as_ref(),
1396                path!("/workspace/backend").as_ref(),
1397                path!("/workspace/shared").as_ref(),
1398            ],
1399            cx,
1400        )
1401        .await;
1402        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1403        let context_server_registry =
1404            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1405        let model = Arc::new(FakeLanguageModel::default());
1406        let thread = cx.new(|cx| {
1407            Thread::new(
1408                project.clone(),
1409                cx.new(|_cx| ProjectContext::default()),
1410                context_server_registry.clone(),
1411                Templates::new(),
1412                Some(model.clone()),
1413                cx,
1414            )
1415        });
1416        let tool = Arc::new(EditFileTool::new(
1417            project.clone(),
1418            thread.downgrade(),
1419            language_registry,
1420            Templates::new(),
1421        ));
1422
1423        // Test files in different worktrees
1424        let test_cases = vec![
1425            ("frontend/src/main.js", false, "File in first worktree"),
1426            ("backend/src/main.rs", false, "File in second worktree"),
1427            (
1428                "shared/.zed/settings.json",
1429                true,
1430                ".zed file in third worktree",
1431            ),
1432            ("/etc/hosts", true, "Absolute path outside all worktrees"),
1433            (
1434                "../outside/file.txt",
1435                true,
1436                "Relative path outside worktrees",
1437            ),
1438        ];
1439
1440        for (path, should_confirm, description) in test_cases {
1441            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1442            let auth = cx.update(|cx| {
1443                tool.authorize(
1444                    &EditFileToolInput {
1445                        display_description: "Edit file".into(),
1446                        path: path.into(),
1447                        mode: EditFileMode::Edit,
1448                    },
1449                    &stream_tx,
1450                    cx,
1451                )
1452            });
1453
1454            if should_confirm {
1455                stream_rx.expect_authorization().await;
1456            } else {
1457                auth.await.unwrap();
1458                assert!(
1459                    stream_rx.try_next().is_err(),
1460                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1461                    description,
1462                    path
1463                );
1464            }
1465        }
1466    }
1467
1468    #[gpui::test]
1469    async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1470        init_test(cx);
1471        let fs = project::FakeFs::new(cx.executor());
1472        fs.insert_tree(
1473            "/project",
1474            json!({
1475                ".zed": {
1476                    "settings.json": "{}"
1477                },
1478                "src": {
1479                    ".zed": {
1480                        "local.json": "{}"
1481                    }
1482                }
1483            }),
1484        )
1485        .await;
1486        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1487        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1488        let context_server_registry =
1489            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1490        let model = Arc::new(FakeLanguageModel::default());
1491        let thread = cx.new(|cx| {
1492            Thread::new(
1493                project.clone(),
1494                cx.new(|_cx| ProjectContext::default()),
1495                context_server_registry.clone(),
1496                Templates::new(),
1497                Some(model.clone()),
1498                cx,
1499            )
1500        });
1501        let tool = Arc::new(EditFileTool::new(
1502            project.clone(),
1503            thread.downgrade(),
1504            language_registry,
1505            Templates::new(),
1506        ));
1507
1508        // Test edge cases
1509        let test_cases = vec![
1510            // Empty path - find_project_path returns Some for empty paths
1511            ("", false, "Empty path is treated as project root"),
1512            // Root directory
1513            ("/", true, "Root directory should be outside project"),
1514            // Parent directory references - find_project_path resolves these
1515            (
1516                "project/../other",
1517                true,
1518                "Path with .. that goes outside of root directory",
1519            ),
1520            (
1521                "project/./src/file.rs",
1522                false,
1523                "Path with . should work normally",
1524            ),
1525            // Windows-style paths (if on Windows)
1526            #[cfg(target_os = "windows")]
1527            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1528            #[cfg(target_os = "windows")]
1529            ("project\\src\\main.rs", false, "Windows-style project path"),
1530        ];
1531
1532        for (path, should_confirm, description) in test_cases {
1533            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1534            let auth = cx.update(|cx| {
1535                tool.authorize(
1536                    &EditFileToolInput {
1537                        display_description: "Edit file".into(),
1538                        path: path.into(),
1539                        mode: EditFileMode::Edit,
1540                    },
1541                    &stream_tx,
1542                    cx,
1543                )
1544            });
1545
1546            cx.run_until_parked();
1547
1548            if should_confirm {
1549                stream_rx.expect_authorization().await;
1550            } else {
1551                assert!(
1552                    stream_rx.try_next().is_err(),
1553                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1554                    description,
1555                    path
1556                );
1557                auth.await.unwrap();
1558            }
1559        }
1560    }
1561
1562    #[gpui::test]
1563    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1564        init_test(cx);
1565        let fs = project::FakeFs::new(cx.executor());
1566        fs.insert_tree(
1567            "/project",
1568            json!({
1569                "existing.txt": "content",
1570                ".zed": {
1571                    "settings.json": "{}"
1572                }
1573            }),
1574        )
1575        .await;
1576        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1577        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1578        let context_server_registry =
1579            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1580        let model = Arc::new(FakeLanguageModel::default());
1581        let thread = cx.new(|cx| {
1582            Thread::new(
1583                project.clone(),
1584                cx.new(|_cx| ProjectContext::default()),
1585                context_server_registry.clone(),
1586                Templates::new(),
1587                Some(model.clone()),
1588                cx,
1589            )
1590        });
1591        let tool = Arc::new(EditFileTool::new(
1592            project.clone(),
1593            thread.downgrade(),
1594            language_registry,
1595            Templates::new(),
1596        ));
1597
1598        // Test different EditFileMode values
1599        let modes = vec![
1600            EditFileMode::Edit,
1601            EditFileMode::Create,
1602            EditFileMode::Overwrite,
1603        ];
1604
1605        for mode in modes {
1606            // Test .zed path with different modes
1607            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1608            let _auth = cx.update(|cx| {
1609                tool.authorize(
1610                    &EditFileToolInput {
1611                        display_description: "Edit settings".into(),
1612                        path: "project/.zed/settings.json".into(),
1613                        mode: mode.clone(),
1614                    },
1615                    &stream_tx,
1616                    cx,
1617                )
1618            });
1619
1620            stream_rx.expect_authorization().await;
1621
1622            // Test outside path with different modes
1623            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1624            let _auth = cx.update(|cx| {
1625                tool.authorize(
1626                    &EditFileToolInput {
1627                        display_description: "Edit file".into(),
1628                        path: "/outside/file.txt".into(),
1629                        mode: mode.clone(),
1630                    },
1631                    &stream_tx,
1632                    cx,
1633                )
1634            });
1635
1636            stream_rx.expect_authorization().await;
1637
1638            // Test normal path with different modes
1639            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1640            cx.update(|cx| {
1641                tool.authorize(
1642                    &EditFileToolInput {
1643                        display_description: "Edit file".into(),
1644                        path: "project/normal.txt".into(),
1645                        mode: mode.clone(),
1646                    },
1647                    &stream_tx,
1648                    cx,
1649                )
1650            })
1651            .await
1652            .unwrap();
1653            assert!(stream_rx.try_next().is_err());
1654        }
1655    }
1656
1657    #[gpui::test]
1658    async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1659        init_test(cx);
1660        let fs = project::FakeFs::new(cx.executor());
1661        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1662        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1663        let context_server_registry =
1664            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1665        let model = Arc::new(FakeLanguageModel::default());
1666        let thread = cx.new(|cx| {
1667            Thread::new(
1668                project.clone(),
1669                cx.new(|_cx| ProjectContext::default()),
1670                context_server_registry,
1671                Templates::new(),
1672                Some(model.clone()),
1673                cx,
1674            )
1675        });
1676        let tool = Arc::new(EditFileTool::new(
1677            project,
1678            thread.downgrade(),
1679            language_registry,
1680            Templates::new(),
1681        ));
1682
1683        cx.update(|cx| {
1684            // ...
1685            assert_eq!(
1686                tool.initial_title(
1687                    Err(json!({
1688                        "path": "src/main.rs",
1689                        "display_description": "",
1690                        "old_string": "old code",
1691                        "new_string": "new code"
1692                    })),
1693                    cx
1694                ),
1695                "src/main.rs"
1696            );
1697            assert_eq!(
1698                tool.initial_title(
1699                    Err(json!({
1700                        "path": "",
1701                        "display_description": "Fix error handling",
1702                        "old_string": "old code",
1703                        "new_string": "new code"
1704                    })),
1705                    cx
1706                ),
1707                "Fix error handling"
1708            );
1709            assert_eq!(
1710                tool.initial_title(
1711                    Err(json!({
1712                        "path": "src/main.rs",
1713                        "display_description": "Fix error handling",
1714                        "old_string": "old code",
1715                        "new_string": "new code"
1716                    })),
1717                    cx
1718                ),
1719                "src/main.rs"
1720            );
1721            assert_eq!(
1722                tool.initial_title(
1723                    Err(json!({
1724                        "path": "",
1725                        "display_description": "",
1726                        "old_string": "old code",
1727                        "new_string": "new code"
1728                    })),
1729                    cx
1730                ),
1731                DEFAULT_UI_TEXT
1732            );
1733            assert_eq!(
1734                tool.initial_title(Err(serde_json::Value::Null), cx),
1735                DEFAULT_UI_TEXT
1736            );
1737        });
1738    }
1739
1740    #[gpui::test]
1741    async fn test_diff_finalization(cx: &mut TestAppContext) {
1742        init_test(cx);
1743        let fs = project::FakeFs::new(cx.executor());
1744        fs.insert_tree("/", json!({"main.rs": ""})).await;
1745
1746        let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1747        let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1748        let context_server_registry =
1749            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1750        let model = Arc::new(FakeLanguageModel::default());
1751        let thread = cx.new(|cx| {
1752            Thread::new(
1753                project.clone(),
1754                cx.new(|_cx| ProjectContext::default()),
1755                context_server_registry.clone(),
1756                Templates::new(),
1757                Some(model.clone()),
1758                cx,
1759            )
1760        });
1761
1762        // Ensure the diff is finalized after the edit completes.
1763        {
1764            let tool = Arc::new(EditFileTool::new(
1765                project.clone(),
1766                thread.downgrade(),
1767                languages.clone(),
1768                Templates::new(),
1769            ));
1770            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1771            let edit = cx.update(|cx| {
1772                tool.run(
1773                    EditFileToolInput {
1774                        display_description: "Edit file".into(),
1775                        path: path!("/main.rs").into(),
1776                        mode: EditFileMode::Edit,
1777                    },
1778                    stream_tx,
1779                    cx,
1780                )
1781            });
1782            stream_rx.expect_update_fields().await;
1783            let diff = stream_rx.expect_diff().await;
1784            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1785            cx.run_until_parked();
1786            model.end_last_completion_stream();
1787            edit.await.unwrap();
1788            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1789        }
1790
1791        // Ensure the diff is finalized if an error occurs while editing.
1792        {
1793            model.forbid_requests();
1794            let tool = Arc::new(EditFileTool::new(
1795                project.clone(),
1796                thread.downgrade(),
1797                languages.clone(),
1798                Templates::new(),
1799            ));
1800            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1801            let edit = cx.update(|cx| {
1802                tool.run(
1803                    EditFileToolInput {
1804                        display_description: "Edit file".into(),
1805                        path: path!("/main.rs").into(),
1806                        mode: EditFileMode::Edit,
1807                    },
1808                    stream_tx,
1809                    cx,
1810                )
1811            });
1812            stream_rx.expect_update_fields().await;
1813            let diff = stream_rx.expect_diff().await;
1814            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1815            edit.await.unwrap_err();
1816            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1817            model.allow_requests();
1818        }
1819
1820        // Ensure the diff is finalized if the tool call gets dropped.
1821        {
1822            let tool = Arc::new(EditFileTool::new(
1823                project.clone(),
1824                thread.downgrade(),
1825                languages.clone(),
1826                Templates::new(),
1827            ));
1828            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1829            let edit = cx.update(|cx| {
1830                tool.run(
1831                    EditFileToolInput {
1832                        display_description: "Edit file".into(),
1833                        path: path!("/main.rs").into(),
1834                        mode: EditFileMode::Edit,
1835                    },
1836                    stream_tx,
1837                    cx,
1838                )
1839            });
1840            stream_rx.expect_update_fields().await;
1841            let diff = stream_rx.expect_diff().await;
1842            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1843            drop(edit);
1844            cx.run_until_parked();
1845            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1846        }
1847    }
1848
1849    #[gpui::test]
1850    async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1851        init_test(cx);
1852
1853        let fs = project::FakeFs::new(cx.executor());
1854        fs.insert_tree(
1855            "/root",
1856            json!({
1857                "test.txt": "original content"
1858            }),
1859        )
1860        .await;
1861        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1862        let context_server_registry =
1863            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1864        let model = Arc::new(FakeLanguageModel::default());
1865        let thread = cx.new(|cx| {
1866            Thread::new(
1867                project.clone(),
1868                cx.new(|_cx| ProjectContext::default()),
1869                context_server_registry,
1870                Templates::new(),
1871                Some(model.clone()),
1872                cx,
1873            )
1874        });
1875        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1876
1877        // Initially, file_read_times should be empty
1878        let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1879        assert!(is_empty, "file_read_times should start empty");
1880
1881        // Create read tool
1882        let read_tool = Arc::new(crate::ReadFileTool::new(
1883            thread.downgrade(),
1884            project.clone(),
1885            action_log,
1886        ));
1887
1888        // Read the file to record the read time
1889        cx.update(|cx| {
1890            read_tool.clone().run(
1891                crate::ReadFileToolInput {
1892                    path: "root/test.txt".to_string(),
1893                    start_line: None,
1894                    end_line: None,
1895                },
1896                ToolCallEventStream::test().0,
1897                cx,
1898            )
1899        })
1900        .await
1901        .unwrap();
1902
1903        // Verify that file_read_times now contains an entry for the file
1904        let has_entry = thread.read_with(cx, |thread, _| {
1905            thread.file_read_times.len() == 1
1906                && thread
1907                    .file_read_times
1908                    .keys()
1909                    .any(|path| path.ends_with("test.txt"))
1910        });
1911        assert!(
1912            has_entry,
1913            "file_read_times should contain an entry after reading the file"
1914        );
1915
1916        // Read the file again - should update the entry
1917        cx.update(|cx| {
1918            read_tool.clone().run(
1919                crate::ReadFileToolInput {
1920                    path: "root/test.txt".to_string(),
1921                    start_line: None,
1922                    end_line: None,
1923                },
1924                ToolCallEventStream::test().0,
1925                cx,
1926            )
1927        })
1928        .await
1929        .unwrap();
1930
1931        // Should still have exactly one entry
1932        let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
1933        assert!(
1934            has_one_entry,
1935            "file_read_times should still have one entry after re-reading"
1936        );
1937    }
1938
1939    fn init_test(cx: &mut TestAppContext) {
1940        cx.update(|cx| {
1941            let settings_store = SettingsStore::test(cx);
1942            cx.set_global(settings_store);
1943        });
1944    }
1945
1946    #[gpui::test]
1947    async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
1948        init_test(cx);
1949
1950        let fs = project::FakeFs::new(cx.executor());
1951        fs.insert_tree(
1952            "/root",
1953            json!({
1954                "test.txt": "original content"
1955            }),
1956        )
1957        .await;
1958        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1959        let context_server_registry =
1960            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1961        let model = Arc::new(FakeLanguageModel::default());
1962        let thread = cx.new(|cx| {
1963            Thread::new(
1964                project.clone(),
1965                cx.new(|_cx| ProjectContext::default()),
1966                context_server_registry,
1967                Templates::new(),
1968                Some(model.clone()),
1969                cx,
1970            )
1971        });
1972        let languages = project.read_with(cx, |project, _| project.languages().clone());
1973        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1974
1975        let read_tool = Arc::new(crate::ReadFileTool::new(
1976            thread.downgrade(),
1977            project.clone(),
1978            action_log,
1979        ));
1980        let edit_tool = Arc::new(EditFileTool::new(
1981            project.clone(),
1982            thread.downgrade(),
1983            languages,
1984            Templates::new(),
1985        ));
1986
1987        // Read the file first
1988        cx.update(|cx| {
1989            read_tool.clone().run(
1990                crate::ReadFileToolInput {
1991                    path: "root/test.txt".to_string(),
1992                    start_line: None,
1993                    end_line: None,
1994                },
1995                ToolCallEventStream::test().0,
1996                cx,
1997            )
1998        })
1999        .await
2000        .unwrap();
2001
2002        // First edit should work
2003        let edit_result = {
2004            let edit_task = cx.update(|cx| {
2005                edit_tool.clone().run(
2006                    EditFileToolInput {
2007                        display_description: "First edit".into(),
2008                        path: "root/test.txt".into(),
2009                        mode: EditFileMode::Edit,
2010                    },
2011                    ToolCallEventStream::test().0,
2012                    cx,
2013                )
2014            });
2015
2016            cx.executor().run_until_parked();
2017            model.send_last_completion_stream_text_chunk(
2018                "<old_text>original content</old_text><new_text>modified content</new_text>"
2019                    .to_string(),
2020            );
2021            model.end_last_completion_stream();
2022
2023            edit_task.await
2024        };
2025        assert!(
2026            edit_result.is_ok(),
2027            "First edit should succeed, got error: {:?}",
2028            edit_result.as_ref().err()
2029        );
2030
2031        // Second edit should also work because the edit updated the recorded read time
2032        let edit_result = {
2033            let edit_task = cx.update(|cx| {
2034                edit_tool.clone().run(
2035                    EditFileToolInput {
2036                        display_description: "Second edit".into(),
2037                        path: "root/test.txt".into(),
2038                        mode: EditFileMode::Edit,
2039                    },
2040                    ToolCallEventStream::test().0,
2041                    cx,
2042                )
2043            });
2044
2045            cx.executor().run_until_parked();
2046            model.send_last_completion_stream_text_chunk(
2047                "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2048            );
2049            model.end_last_completion_stream();
2050
2051            edit_task.await
2052        };
2053        assert!(
2054            edit_result.is_ok(),
2055            "Second consecutive edit should succeed, got error: {:?}",
2056            edit_result.as_ref().err()
2057        );
2058    }
2059
2060    #[gpui::test]
2061    async fn test_external_modification_detected(cx: &mut TestAppContext) {
2062        init_test(cx);
2063
2064        let fs = project::FakeFs::new(cx.executor());
2065        fs.insert_tree(
2066            "/root",
2067            json!({
2068                "test.txt": "original content"
2069            }),
2070        )
2071        .await;
2072        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2073        let context_server_registry =
2074            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2075        let model = Arc::new(FakeLanguageModel::default());
2076        let thread = cx.new(|cx| {
2077            Thread::new(
2078                project.clone(),
2079                cx.new(|_cx| ProjectContext::default()),
2080                context_server_registry,
2081                Templates::new(),
2082                Some(model.clone()),
2083                cx,
2084            )
2085        });
2086        let languages = project.read_with(cx, |project, _| project.languages().clone());
2087        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2088
2089        let read_tool = Arc::new(crate::ReadFileTool::new(
2090            thread.downgrade(),
2091            project.clone(),
2092            action_log,
2093        ));
2094        let edit_tool = Arc::new(EditFileTool::new(
2095            project.clone(),
2096            thread.downgrade(),
2097            languages,
2098            Templates::new(),
2099        ));
2100
2101        // Read the file first
2102        cx.update(|cx| {
2103            read_tool.clone().run(
2104                crate::ReadFileToolInput {
2105                    path: "root/test.txt".to_string(),
2106                    start_line: None,
2107                    end_line: None,
2108                },
2109                ToolCallEventStream::test().0,
2110                cx,
2111            )
2112        })
2113        .await
2114        .unwrap();
2115
2116        // Simulate external modification - advance time and save file
2117        cx.background_executor
2118            .advance_clock(std::time::Duration::from_secs(2));
2119        fs.save(
2120            path!("/root/test.txt").as_ref(),
2121            &"externally modified content".into(),
2122            language::LineEnding::Unix,
2123        )
2124        .await
2125        .unwrap();
2126
2127        // Reload the buffer to pick up the new mtime
2128        let project_path = project
2129            .read_with(cx, |project, cx| {
2130                project.find_project_path("root/test.txt", cx)
2131            })
2132            .expect("Should find project path");
2133        let buffer = project
2134            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2135            .await
2136            .unwrap();
2137        buffer
2138            .update(cx, |buffer, cx| buffer.reload(cx))
2139            .await
2140            .unwrap();
2141
2142        cx.executor().run_until_parked();
2143
2144        // Try to edit - should fail because file was modified externally
2145        let result = cx
2146            .update(|cx| {
2147                edit_tool.clone().run(
2148                    EditFileToolInput {
2149                        display_description: "Edit after external change".into(),
2150                        path: "root/test.txt".into(),
2151                        mode: EditFileMode::Edit,
2152                    },
2153                    ToolCallEventStream::test().0,
2154                    cx,
2155                )
2156            })
2157            .await;
2158
2159        assert!(
2160            result.is_err(),
2161            "Edit should fail after external modification"
2162        );
2163        let error_msg = result.unwrap_err().to_string();
2164        assert!(
2165            error_msg.contains("has been modified since you last read it"),
2166            "Error should mention file modification, got: {}",
2167            error_msg
2168        );
2169    }
2170
2171    #[gpui::test]
2172    async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2173        init_test(cx);
2174
2175        let fs = project::FakeFs::new(cx.executor());
2176        fs.insert_tree(
2177            "/root",
2178            json!({
2179                "test.txt": "original content"
2180            }),
2181        )
2182        .await;
2183        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2184        let context_server_registry =
2185            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2186        let model = Arc::new(FakeLanguageModel::default());
2187        let thread = cx.new(|cx| {
2188            Thread::new(
2189                project.clone(),
2190                cx.new(|_cx| ProjectContext::default()),
2191                context_server_registry,
2192                Templates::new(),
2193                Some(model.clone()),
2194                cx,
2195            )
2196        });
2197        let languages = project.read_with(cx, |project, _| project.languages().clone());
2198        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2199
2200        let read_tool = Arc::new(crate::ReadFileTool::new(
2201            thread.downgrade(),
2202            project.clone(),
2203            action_log,
2204        ));
2205        let edit_tool = Arc::new(EditFileTool::new(
2206            project.clone(),
2207            thread.downgrade(),
2208            languages,
2209            Templates::new(),
2210        ));
2211
2212        // Read the file first
2213        cx.update(|cx| {
2214            read_tool.clone().run(
2215                crate::ReadFileToolInput {
2216                    path: "root/test.txt".to_string(),
2217                    start_line: None,
2218                    end_line: None,
2219                },
2220                ToolCallEventStream::test().0,
2221                cx,
2222            )
2223        })
2224        .await
2225        .unwrap();
2226
2227        // Open the buffer and make it dirty by editing without saving
2228        let project_path = project
2229            .read_with(cx, |project, cx| {
2230                project.find_project_path("root/test.txt", cx)
2231            })
2232            .expect("Should find project path");
2233        let buffer = project
2234            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2235            .await
2236            .unwrap();
2237
2238        // Make an in-memory edit to the buffer (making it dirty)
2239        buffer.update(cx, |buffer, cx| {
2240            let end_point = buffer.max_point();
2241            buffer.edit([(end_point..end_point, " added text")], None, cx);
2242        });
2243
2244        // Verify buffer is dirty
2245        let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2246        assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2247
2248        // Try to edit - should fail because buffer has unsaved changes
2249        let result = cx
2250            .update(|cx| {
2251                edit_tool.clone().run(
2252                    EditFileToolInput {
2253                        display_description: "Edit with dirty buffer".into(),
2254                        path: "root/test.txt".into(),
2255                        mode: EditFileMode::Edit,
2256                    },
2257                    ToolCallEventStream::test().0,
2258                    cx,
2259                )
2260            })
2261            .await;
2262
2263        assert!(result.is_err(), "Edit should fail when buffer is dirty");
2264        let error_msg = result.unwrap_err().to_string();
2265        assert!(
2266            error_msg.contains("This file has unsaved changes."),
2267            "Error should mention unsaved changes, got: {}",
2268            error_msg
2269        );
2270        assert!(
2271            error_msg.contains("keep or discard"),
2272            "Error should ask whether to keep or discard changes, got: {}",
2273            error_msg
2274        );
2275        // Since save_file and restore_file_from_disk tools aren't added to the thread,
2276        // the error message should ask the user to manually save or revert
2277        assert!(
2278            error_msg.contains("save or revert the file manually"),
2279            "Error should ask user to manually save or revert when tools aren't available, got: {}",
2280            error_msg
2281        );
2282    }
2283}