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.read_with(cx, |buffer, cx| {
 472                let settings = language_settings::language_settings(
 473                    buffer.language().map(|l| l.name()),
 474                    buffer.file(),
 475                    cx,
 476                );
 477                settings.format_on_save != FormatOnSave::Off
 478            });
 479
 480            let edit_agent_output = output.await?;
 481
 482            if format_on_save_enabled {
 483                action_log.update(cx, |log, cx| {
 484                    log.buffer_edited(buffer.clone(), cx);
 485                });
 486
 487                let format_task = project.update(cx, |project, cx| {
 488                    project.format(
 489                        HashSet::from_iter([buffer.clone()]),
 490                        LspFormatTarget::Buffers,
 491                        false, // Don't push to history since the tool did it.
 492                        FormatTrigger::Save,
 493                        cx,
 494                    )
 495                });
 496                format_task.await.log_err();
 497            }
 498
 499            project
 500                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
 501                .await?;
 502
 503            action_log.update(cx, |log, cx| {
 504                log.buffer_edited(buffer.clone(), cx);
 505            });
 506
 507            // Update the recorded read time after a successful edit so consecutive edits work
 508            if let Some(abs_path) = abs_path.as_ref() {
 509                if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
 510                    buffer.file().and_then(|file| file.disk_state().mtime())
 511                }) {
 512                    self.thread.update(cx, |thread, _| {
 513                        thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
 514                    })?;
 515                }
 516            }
 517
 518            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 519            let (new_text, unified_diff) = cx
 520                .background_spawn({
 521                    let new_snapshot = new_snapshot.clone();
 522                    let old_text = old_text.clone();
 523                    async move {
 524                        let new_text = new_snapshot.text();
 525                        let diff = language::unified_diff(&old_text, &new_text);
 526                        (new_text, diff)
 527                    }
 528                })
 529                .await;
 530
 531            let input_path = input.path.display();
 532            if unified_diff.is_empty() {
 533                anyhow::ensure!(
 534                    !hallucinated_old_text,
 535                    formatdoc! {"
 536                        Some edits were produced but none of them could be applied.
 537                        Read the relevant sections of {input_path} again so that
 538                        I can perform the requested edits.
 539                    "}
 540                );
 541                anyhow::ensure!(
 542                    ambiguous_ranges.is_empty(),
 543                    {
 544                        let line_numbers = ambiguous_ranges
 545                            .iter()
 546                            .map(|range| range.start.to_string())
 547                            .collect::<Vec<_>>()
 548                            .join(", ");
 549                        formatdoc! {"
 550                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
 551                            relevant sections of {input_path} again and extend <old_text> so
 552                            that I can perform the requested edits.
 553                        "}
 554                    }
 555                );
 556            }
 557
 558            Ok(EditFileToolOutput {
 559                input_path: input.path,
 560                new_text,
 561                old_text,
 562                diff: unified_diff,
 563                edit_agent_output,
 564            })
 565        })
 566    }
 567
 568    fn replay(
 569        &self,
 570        _input: Self::Input,
 571        output: Self::Output,
 572        event_stream: ToolCallEventStream,
 573        cx: &mut App,
 574    ) -> Result<()> {
 575        event_stream.update_diff(cx.new(|cx| {
 576            Diff::finalized(
 577                output.input_path.to_string_lossy().into_owned(),
 578                Some(output.old_text.to_string()),
 579                output.new_text,
 580                self.language_registry.clone(),
 581                cx,
 582            )
 583        }));
 584        Ok(())
 585    }
 586
 587    fn rebind_thread(
 588        &self,
 589        new_thread: gpui::WeakEntity<crate::Thread>,
 590    ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
 591        Some(self.with_thread(new_thread).erase())
 592    }
 593}
 594
 595/// Validate that the file path is valid, meaning:
 596///
 597/// - For `edit` and `overwrite`, the path must point to an existing file.
 598/// - For `create`, the file must not already exist, but it's parent dir must exist.
 599fn resolve_path(
 600    input: &EditFileToolInput,
 601    project: Entity<Project>,
 602    cx: &mut App,
 603) -> Result<ProjectPath> {
 604    let project = project.read(cx);
 605
 606    match input.mode {
 607        EditFileMode::Edit | EditFileMode::Overwrite => {
 608            let path = project
 609                .find_project_path(&input.path, cx)
 610                .context("Can't edit file: path not found")?;
 611
 612            let entry = project
 613                .entry_for_path(&path, cx)
 614                .context("Can't edit file: path not found")?;
 615
 616            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 617            Ok(path)
 618        }
 619
 620        EditFileMode::Create => {
 621            if let Some(path) = project.find_project_path(&input.path, cx) {
 622                anyhow::ensure!(
 623                    project.entry_for_path(&path, cx).is_none(),
 624                    "Can't create file: file already exists"
 625                );
 626            }
 627
 628            let parent_path = input
 629                .path
 630                .parent()
 631                .context("Can't create file: incorrect path")?;
 632
 633            let parent_project_path = project.find_project_path(&parent_path, cx);
 634
 635            let parent_entry = parent_project_path
 636                .as_ref()
 637                .and_then(|path| project.entry_for_path(path, cx))
 638                .context("Can't create file: parent directory doesn't exist")?;
 639
 640            anyhow::ensure!(
 641                parent_entry.is_dir(),
 642                "Can't create file: parent is not a directory"
 643            );
 644
 645            let file_name = input
 646                .path
 647                .file_name()
 648                .and_then(|file_name| file_name.to_str())
 649                .and_then(|file_name| RelPath::unix(file_name).ok())
 650                .context("Can't create file: invalid filename")?;
 651
 652            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 653                path: parent.path.join(file_name),
 654                ..parent
 655            });
 656
 657            new_file_path.context("Can't create file")
 658        }
 659    }
 660}
 661
 662#[cfg(test)]
 663mod tests {
 664    use super::*;
 665    use crate::{ContextServerRegistry, Templates};
 666    use fs::Fs;
 667    use gpui::{TestAppContext, UpdateGlobal};
 668    use language_model::fake_provider::FakeLanguageModel;
 669    use prompt_store::ProjectContext;
 670    use serde_json::json;
 671    use settings::SettingsStore;
 672    use util::{path, rel_path::rel_path};
 673
 674    #[gpui::test]
 675    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 676        init_test(cx);
 677
 678        let fs = project::FakeFs::new(cx.executor());
 679        fs.insert_tree("/root", json!({})).await;
 680        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 681        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
 682        let context_server_registry =
 683            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 684        let model = Arc::new(FakeLanguageModel::default());
 685        let thread = cx.new(|cx| {
 686            Thread::new(
 687                project.clone(),
 688                cx.new(|_cx| ProjectContext::default()),
 689                context_server_registry,
 690                Templates::new(),
 691                Some(model),
 692                cx,
 693            )
 694        });
 695        let result = cx
 696            .update(|cx| {
 697                let input = EditFileToolInput {
 698                    display_description: "Some edit".into(),
 699                    path: "root/nonexistent_file.txt".into(),
 700                    mode: EditFileMode::Edit,
 701                };
 702                Arc::new(EditFileTool::new(
 703                    project,
 704                    thread.downgrade(),
 705                    language_registry,
 706                    Templates::new(),
 707                ))
 708                .run(input, ToolCallEventStream::test().0, cx)
 709            })
 710            .await;
 711        assert_eq!(
 712            result.unwrap_err().to_string(),
 713            "Can't edit file: path not found"
 714        );
 715    }
 716
 717    #[gpui::test]
 718    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
 719        let mode = &EditFileMode::Create;
 720
 721        let result = test_resolve_path(mode, "root/new.txt", cx);
 722        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 723
 724        let result = test_resolve_path(mode, "new.txt", cx);
 725        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 726
 727        let result = test_resolve_path(mode, "dir/new.txt", cx);
 728        assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
 729
 730        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
 731        assert_eq!(
 732            result.await.unwrap_err().to_string(),
 733            "Can't create file: file already exists"
 734        );
 735
 736        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
 737        assert_eq!(
 738            result.await.unwrap_err().to_string(),
 739            "Can't create file: parent directory doesn't exist"
 740        );
 741    }
 742
 743    #[gpui::test]
 744    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
 745        let mode = &EditFileMode::Edit;
 746
 747        let path_with_root = "root/dir/subdir/existing.txt";
 748        let path_without_root = "dir/subdir/existing.txt";
 749        let result = test_resolve_path(mode, path_with_root, cx);
 750        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 751
 752        let result = test_resolve_path(mode, path_without_root, cx);
 753        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 754
 755        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
 756        assert_eq!(
 757            result.await.unwrap_err().to_string(),
 758            "Can't edit file: path not found"
 759        );
 760
 761        let result = test_resolve_path(mode, "root/dir", cx);
 762        assert_eq!(
 763            result.await.unwrap_err().to_string(),
 764            "Can't edit file: path is a directory"
 765        );
 766    }
 767
 768    async fn test_resolve_path(
 769        mode: &EditFileMode,
 770        path: &str,
 771        cx: &mut TestAppContext,
 772    ) -> anyhow::Result<ProjectPath> {
 773        init_test(cx);
 774
 775        let fs = project::FakeFs::new(cx.executor());
 776        fs.insert_tree(
 777            "/root",
 778            json!({
 779                "dir": {
 780                    "subdir": {
 781                        "existing.txt": "hello"
 782                    }
 783                }
 784            }),
 785        )
 786        .await;
 787        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 788
 789        let input = EditFileToolInput {
 790            display_description: "Some edit".into(),
 791            path: path.into(),
 792            mode: mode.clone(),
 793        };
 794
 795        cx.update(|cx| resolve_path(&input, project, cx))
 796    }
 797
 798    #[track_caller]
 799    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
 800        let actual = path.expect("Should return valid path").path;
 801        assert_eq!(actual.as_ref(), expected);
 802    }
 803
 804    #[gpui::test]
 805    async fn test_format_on_save(cx: &mut TestAppContext) {
 806        init_test(cx);
 807
 808        let fs = project::FakeFs::new(cx.executor());
 809        fs.insert_tree("/root", json!({"src": {}})).await;
 810
 811        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 812
 813        // Set up a Rust language with LSP formatting support
 814        let rust_language = Arc::new(language::Language::new(
 815            language::LanguageConfig {
 816                name: "Rust".into(),
 817                matcher: language::LanguageMatcher {
 818                    path_suffixes: vec!["rs".to_string()],
 819                    ..Default::default()
 820                },
 821                ..Default::default()
 822            },
 823            None,
 824        ));
 825
 826        // Register the language and fake LSP
 827        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 828        language_registry.add(rust_language);
 829
 830        let mut fake_language_servers = language_registry.register_fake_lsp(
 831            "Rust",
 832            language::FakeLspAdapter {
 833                capabilities: lsp::ServerCapabilities {
 834                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
 835                    ..Default::default()
 836                },
 837                ..Default::default()
 838            },
 839        );
 840
 841        // Create the file
 842        fs.save(
 843            path!("/root/src/main.rs").as_ref(),
 844            &"initial content".into(),
 845            language::LineEnding::Unix,
 846        )
 847        .await
 848        .unwrap();
 849
 850        // Open the buffer to trigger LSP initialization
 851        let buffer = project
 852            .update(cx, |project, cx| {
 853                project.open_local_buffer(path!("/root/src/main.rs"), cx)
 854            })
 855            .await
 856            .unwrap();
 857
 858        // Register the buffer with language servers
 859        let _handle = project.update(cx, |project, cx| {
 860            project.register_buffer_with_language_servers(&buffer, cx)
 861        });
 862
 863        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
 864        const FORMATTED_CONTENT: &str =
 865            "This file was formatted by the fake formatter in the test.\n";
 866
 867        // Get the fake language server and set up formatting handler
 868        let fake_language_server = fake_language_servers.next().await.unwrap();
 869        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
 870            |_, _| async move {
 871                Ok(Some(vec![lsp::TextEdit {
 872                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
 873                    new_text: FORMATTED_CONTENT.to_string(),
 874                }]))
 875            }
 876        });
 877
 878        let context_server_registry =
 879            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 880        let model = Arc::new(FakeLanguageModel::default());
 881        let thread = cx.new(|cx| {
 882            Thread::new(
 883                project.clone(),
 884                cx.new(|_cx| ProjectContext::default()),
 885                context_server_registry,
 886                Templates::new(),
 887                Some(model.clone()),
 888                cx,
 889            )
 890        });
 891
 892        // First, test with format_on_save enabled
 893        cx.update(|cx| {
 894            SettingsStore::update_global(cx, |store, cx| {
 895                store.update_user_settings(cx, |settings| {
 896                    settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
 897                    settings.project.all_languages.defaults.formatter =
 898                        Some(language::language_settings::FormatterList::default());
 899                });
 900            });
 901        });
 902
 903        // Have the model stream unformatted content
 904        let edit_result = {
 905            let edit_task = cx.update(|cx| {
 906                let input = EditFileToolInput {
 907                    display_description: "Create main function".into(),
 908                    path: "root/src/main.rs".into(),
 909                    mode: EditFileMode::Overwrite,
 910                };
 911                Arc::new(EditFileTool::new(
 912                    project.clone(),
 913                    thread.downgrade(),
 914                    language_registry.clone(),
 915                    Templates::new(),
 916                ))
 917                .run(input, ToolCallEventStream::test().0, cx)
 918            });
 919
 920            // Stream the unformatted content
 921            cx.executor().run_until_parked();
 922            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 923            model.end_last_completion_stream();
 924
 925            edit_task.await
 926        };
 927        assert!(edit_result.is_ok());
 928
 929        // Wait for any async operations (e.g. formatting) to complete
 930        cx.executor().run_until_parked();
 931
 932        // Read the file to verify it was formatted automatically
 933        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 934        assert_eq!(
 935            // Ignore carriage returns on Windows
 936            new_content.replace("\r\n", "\n"),
 937            FORMATTED_CONTENT,
 938            "Code should be formatted when format_on_save is enabled"
 939        );
 940
 941        let stale_buffer_count = thread
 942            .read_with(cx, |thread, _cx| thread.action_log.clone())
 943            .read_with(cx, |log, cx| log.stale_buffers(cx).count());
 944
 945        assert_eq!(
 946            stale_buffer_count, 0,
 947            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
 948             This causes the agent to think the file was modified externally when it was just formatted.",
 949            stale_buffer_count
 950        );
 951
 952        // Next, test with format_on_save disabled
 953        cx.update(|cx| {
 954            SettingsStore::update_global(cx, |store, cx| {
 955                store.update_user_settings(cx, |settings| {
 956                    settings.project.all_languages.defaults.format_on_save =
 957                        Some(FormatOnSave::Off);
 958                });
 959            });
 960        });
 961
 962        // Stream unformatted edits again
 963        let edit_result = {
 964            let edit_task = cx.update(|cx| {
 965                let input = EditFileToolInput {
 966                    display_description: "Update main function".into(),
 967                    path: "root/src/main.rs".into(),
 968                    mode: EditFileMode::Overwrite,
 969                };
 970                Arc::new(EditFileTool::new(
 971                    project.clone(),
 972                    thread.downgrade(),
 973                    language_registry,
 974                    Templates::new(),
 975                ))
 976                .run(input, ToolCallEventStream::test().0, cx)
 977            });
 978
 979            // Stream the unformatted content
 980            cx.executor().run_until_parked();
 981            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 982            model.end_last_completion_stream();
 983
 984            edit_task.await
 985        };
 986        assert!(edit_result.is_ok());
 987
 988        // Wait for any async operations (e.g. formatting) to complete
 989        cx.executor().run_until_parked();
 990
 991        // Verify the file was not formatted
 992        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 993        assert_eq!(
 994            // Ignore carriage returns on Windows
 995            new_content.replace("\r\n", "\n"),
 996            UNFORMATTED_CONTENT,
 997            "Code should not be formatted when format_on_save is disabled"
 998        );
 999    }
1000
1001    #[gpui::test]
1002    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1003        init_test(cx);
1004
1005        let fs = project::FakeFs::new(cx.executor());
1006        fs.insert_tree("/root", json!({"src": {}})).await;
1007
1008        // Create a simple file with trailing whitespace
1009        fs.save(
1010            path!("/root/src/main.rs").as_ref(),
1011            &"initial content".into(),
1012            language::LineEnding::Unix,
1013        )
1014        .await
1015        .unwrap();
1016
1017        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1018        let context_server_registry =
1019            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1020        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1021        let model = Arc::new(FakeLanguageModel::default());
1022        let thread = cx.new(|cx| {
1023            Thread::new(
1024                project.clone(),
1025                cx.new(|_cx| ProjectContext::default()),
1026                context_server_registry,
1027                Templates::new(),
1028                Some(model.clone()),
1029                cx,
1030            )
1031        });
1032
1033        // First, test with remove_trailing_whitespace_on_save enabled
1034        cx.update(|cx| {
1035            SettingsStore::update_global(cx, |store, cx| {
1036                store.update_user_settings(cx, |settings| {
1037                    settings
1038                        .project
1039                        .all_languages
1040                        .defaults
1041                        .remove_trailing_whitespace_on_save = Some(true);
1042                });
1043            });
1044        });
1045
1046        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1047            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
1048
1049        // Have the model stream content that contains trailing whitespace
1050        let edit_result = {
1051            let edit_task = cx.update(|cx| {
1052                let input = EditFileToolInput {
1053                    display_description: "Create main function".into(),
1054                    path: "root/src/main.rs".into(),
1055                    mode: EditFileMode::Overwrite,
1056                };
1057                Arc::new(EditFileTool::new(
1058                    project.clone(),
1059                    thread.downgrade(),
1060                    language_registry.clone(),
1061                    Templates::new(),
1062                ))
1063                .run(input, ToolCallEventStream::test().0, cx)
1064            });
1065
1066            // Stream the content with trailing whitespace
1067            cx.executor().run_until_parked();
1068            model.send_last_completion_stream_text_chunk(
1069                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1070            );
1071            model.end_last_completion_stream();
1072
1073            edit_task.await
1074        };
1075        assert!(edit_result.is_ok());
1076
1077        // Wait for any async operations (e.g. formatting) to complete
1078        cx.executor().run_until_parked();
1079
1080        // Read the file to verify trailing whitespace was removed automatically
1081        assert_eq!(
1082            // Ignore carriage returns on Windows
1083            fs.load(path!("/root/src/main.rs").as_ref())
1084                .await
1085                .unwrap()
1086                .replace("\r\n", "\n"),
1087            "fn main() {\n    println!(\"Hello!\");\n}\n",
1088            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1089        );
1090
1091        // Next, test with remove_trailing_whitespace_on_save disabled
1092        cx.update(|cx| {
1093            SettingsStore::update_global(cx, |store, cx| {
1094                store.update_user_settings(cx, |settings| {
1095                    settings
1096                        .project
1097                        .all_languages
1098                        .defaults
1099                        .remove_trailing_whitespace_on_save = Some(false);
1100                });
1101            });
1102        });
1103
1104        // Stream edits again with trailing whitespace
1105        let edit_result = {
1106            let edit_task = cx.update(|cx| {
1107                let input = EditFileToolInput {
1108                    display_description: "Update main function".into(),
1109                    path: "root/src/main.rs".into(),
1110                    mode: EditFileMode::Overwrite,
1111                };
1112                Arc::new(EditFileTool::new(
1113                    project.clone(),
1114                    thread.downgrade(),
1115                    language_registry,
1116                    Templates::new(),
1117                ))
1118                .run(input, ToolCallEventStream::test().0, cx)
1119            });
1120
1121            // Stream the content with trailing whitespace
1122            cx.executor().run_until_parked();
1123            model.send_last_completion_stream_text_chunk(
1124                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1125            );
1126            model.end_last_completion_stream();
1127
1128            edit_task.await
1129        };
1130        assert!(edit_result.is_ok());
1131
1132        // Wait for any async operations (e.g. formatting) to complete
1133        cx.executor().run_until_parked();
1134
1135        // Verify the file still has trailing whitespace
1136        // Read the file again - it should still have trailing whitespace
1137        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1138        assert_eq!(
1139            // Ignore carriage returns on Windows
1140            final_content.replace("\r\n", "\n"),
1141            CONTENT_WITH_TRAILING_WHITESPACE,
1142            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1143        );
1144    }
1145
1146    #[gpui::test]
1147    async fn test_authorize(cx: &mut TestAppContext) {
1148        init_test(cx);
1149        let fs = project::FakeFs::new(cx.executor());
1150        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1151        let context_server_registry =
1152            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1153        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1154        let model = Arc::new(FakeLanguageModel::default());
1155        let thread = cx.new(|cx| {
1156            Thread::new(
1157                project.clone(),
1158                cx.new(|_cx| ProjectContext::default()),
1159                context_server_registry,
1160                Templates::new(),
1161                Some(model.clone()),
1162                cx,
1163            )
1164        });
1165        let tool = Arc::new(EditFileTool::new(
1166            project.clone(),
1167            thread.downgrade(),
1168            language_registry,
1169            Templates::new(),
1170        ));
1171        fs.insert_tree("/root", json!({})).await;
1172
1173        // Test 1: Path with .zed component should require confirmation
1174        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1175        let _auth = cx.update(|cx| {
1176            tool.authorize(
1177                &EditFileToolInput {
1178                    display_description: "test 1".into(),
1179                    path: ".zed/settings.json".into(),
1180                    mode: EditFileMode::Edit,
1181                },
1182                &stream_tx,
1183                cx,
1184            )
1185        });
1186
1187        let event = stream_rx.expect_authorization().await;
1188        assert_eq!(
1189            event.tool_call.fields.title,
1190            Some("test 1 (local settings)".into())
1191        );
1192
1193        // Test 2: Path outside project should require confirmation
1194        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1195        let _auth = cx.update(|cx| {
1196            tool.authorize(
1197                &EditFileToolInput {
1198                    display_description: "test 2".into(),
1199                    path: "/etc/hosts".into(),
1200                    mode: EditFileMode::Edit,
1201                },
1202                &stream_tx,
1203                cx,
1204            )
1205        });
1206
1207        let event = stream_rx.expect_authorization().await;
1208        assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1209
1210        // Test 3: Relative path without .zed should not require confirmation
1211        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1212        cx.update(|cx| {
1213            tool.authorize(
1214                &EditFileToolInput {
1215                    display_description: "test 3".into(),
1216                    path: "root/src/main.rs".into(),
1217                    mode: EditFileMode::Edit,
1218                },
1219                &stream_tx,
1220                cx,
1221            )
1222        })
1223        .await
1224        .unwrap();
1225        assert!(stream_rx.try_next().is_err());
1226
1227        // Test 4: Path with .zed in the middle should require confirmation
1228        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1229        let _auth = cx.update(|cx| {
1230            tool.authorize(
1231                &EditFileToolInput {
1232                    display_description: "test 4".into(),
1233                    path: "root/.zed/tasks.json".into(),
1234                    mode: EditFileMode::Edit,
1235                },
1236                &stream_tx,
1237                cx,
1238            )
1239        });
1240        let event = stream_rx.expect_authorization().await;
1241        assert_eq!(
1242            event.tool_call.fields.title,
1243            Some("test 4 (local settings)".into())
1244        );
1245
1246        // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1247        cx.update(|cx| {
1248            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1249            settings.always_allow_tool_actions = true;
1250            agent_settings::AgentSettings::override_global(settings, cx);
1251        });
1252
1253        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1254        cx.update(|cx| {
1255            tool.authorize(
1256                &EditFileToolInput {
1257                    display_description: "test 5.1".into(),
1258                    path: ".zed/settings.json".into(),
1259                    mode: EditFileMode::Edit,
1260                },
1261                &stream_tx,
1262                cx,
1263            )
1264        })
1265        .await
1266        .unwrap();
1267        assert!(stream_rx.try_next().is_err());
1268
1269        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1270        cx.update(|cx| {
1271            tool.authorize(
1272                &EditFileToolInput {
1273                    display_description: "test 5.2".into(),
1274                    path: "/etc/hosts".into(),
1275                    mode: EditFileMode::Edit,
1276                },
1277                &stream_tx,
1278                cx,
1279            )
1280        })
1281        .await
1282        .unwrap();
1283        assert!(stream_rx.try_next().is_err());
1284    }
1285
1286    #[gpui::test]
1287    async fn test_authorize_global_config(cx: &mut TestAppContext) {
1288        init_test(cx);
1289        let fs = project::FakeFs::new(cx.executor());
1290        fs.insert_tree("/project", json!({})).await;
1291        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1292        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1293        let context_server_registry =
1294            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1295        let model = Arc::new(FakeLanguageModel::default());
1296        let thread = cx.new(|cx| {
1297            Thread::new(
1298                project.clone(),
1299                cx.new(|_cx| ProjectContext::default()),
1300                context_server_registry,
1301                Templates::new(),
1302                Some(model.clone()),
1303                cx,
1304            )
1305        });
1306        let tool = Arc::new(EditFileTool::new(
1307            project.clone(),
1308            thread.downgrade(),
1309            language_registry,
1310            Templates::new(),
1311        ));
1312
1313        // Test global config paths - these should require confirmation if they exist and are outside the project
1314        let test_cases = vec![
1315            (
1316                "/etc/hosts",
1317                true,
1318                "System file should require confirmation",
1319            ),
1320            (
1321                "/usr/local/bin/script",
1322                true,
1323                "System bin file should require confirmation",
1324            ),
1325            (
1326                "project/normal_file.rs",
1327                false,
1328                "Normal project file should not require confirmation",
1329            ),
1330        ];
1331
1332        for (path, should_confirm, description) in test_cases {
1333            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1334            let auth = cx.update(|cx| {
1335                tool.authorize(
1336                    &EditFileToolInput {
1337                        display_description: "Edit file".into(),
1338                        path: path.into(),
1339                        mode: EditFileMode::Edit,
1340                    },
1341                    &stream_tx,
1342                    cx,
1343                )
1344            });
1345
1346            if should_confirm {
1347                stream_rx.expect_authorization().await;
1348            } else {
1349                auth.await.unwrap();
1350                assert!(
1351                    stream_rx.try_next().is_err(),
1352                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1353                    description,
1354                    path
1355                );
1356            }
1357        }
1358    }
1359
1360    #[gpui::test]
1361    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1362        init_test(cx);
1363        let fs = project::FakeFs::new(cx.executor());
1364
1365        // Create multiple worktree directories
1366        fs.insert_tree(
1367            "/workspace/frontend",
1368            json!({
1369                "src": {
1370                    "main.js": "console.log('frontend');"
1371                }
1372            }),
1373        )
1374        .await;
1375        fs.insert_tree(
1376            "/workspace/backend",
1377            json!({
1378                "src": {
1379                    "main.rs": "fn main() {}"
1380                }
1381            }),
1382        )
1383        .await;
1384        fs.insert_tree(
1385            "/workspace/shared",
1386            json!({
1387                ".zed": {
1388                    "settings.json": "{}"
1389                }
1390            }),
1391        )
1392        .await;
1393
1394        // Create project with multiple worktrees
1395        let project = Project::test(
1396            fs.clone(),
1397            [
1398                path!("/workspace/frontend").as_ref(),
1399                path!("/workspace/backend").as_ref(),
1400                path!("/workspace/shared").as_ref(),
1401            ],
1402            cx,
1403        )
1404        .await;
1405        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1406        let context_server_registry =
1407            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1408        let model = Arc::new(FakeLanguageModel::default());
1409        let thread = cx.new(|cx| {
1410            Thread::new(
1411                project.clone(),
1412                cx.new(|_cx| ProjectContext::default()),
1413                context_server_registry.clone(),
1414                Templates::new(),
1415                Some(model.clone()),
1416                cx,
1417            )
1418        });
1419        let tool = Arc::new(EditFileTool::new(
1420            project.clone(),
1421            thread.downgrade(),
1422            language_registry,
1423            Templates::new(),
1424        ));
1425
1426        // Test files in different worktrees
1427        let test_cases = vec![
1428            ("frontend/src/main.js", false, "File in first worktree"),
1429            ("backend/src/main.rs", false, "File in second worktree"),
1430            (
1431                "shared/.zed/settings.json",
1432                true,
1433                ".zed file in third worktree",
1434            ),
1435            ("/etc/hosts", true, "Absolute path outside all worktrees"),
1436            (
1437                "../outside/file.txt",
1438                true,
1439                "Relative path outside worktrees",
1440            ),
1441        ];
1442
1443        for (path, should_confirm, description) in test_cases {
1444            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1445            let auth = cx.update(|cx| {
1446                tool.authorize(
1447                    &EditFileToolInput {
1448                        display_description: "Edit file".into(),
1449                        path: path.into(),
1450                        mode: EditFileMode::Edit,
1451                    },
1452                    &stream_tx,
1453                    cx,
1454                )
1455            });
1456
1457            if should_confirm {
1458                stream_rx.expect_authorization().await;
1459            } else {
1460                auth.await.unwrap();
1461                assert!(
1462                    stream_rx.try_next().is_err(),
1463                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1464                    description,
1465                    path
1466                );
1467            }
1468        }
1469    }
1470
1471    #[gpui::test]
1472    async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1473        init_test(cx);
1474        let fs = project::FakeFs::new(cx.executor());
1475        fs.insert_tree(
1476            "/project",
1477            json!({
1478                ".zed": {
1479                    "settings.json": "{}"
1480                },
1481                "src": {
1482                    ".zed": {
1483                        "local.json": "{}"
1484                    }
1485                }
1486            }),
1487        )
1488        .await;
1489        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1490        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1491        let context_server_registry =
1492            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1493        let model = Arc::new(FakeLanguageModel::default());
1494        let thread = cx.new(|cx| {
1495            Thread::new(
1496                project.clone(),
1497                cx.new(|_cx| ProjectContext::default()),
1498                context_server_registry.clone(),
1499                Templates::new(),
1500                Some(model.clone()),
1501                cx,
1502            )
1503        });
1504        let tool = Arc::new(EditFileTool::new(
1505            project.clone(),
1506            thread.downgrade(),
1507            language_registry,
1508            Templates::new(),
1509        ));
1510
1511        // Test edge cases
1512        let test_cases = vec![
1513            // Empty path - find_project_path returns Some for empty paths
1514            ("", false, "Empty path is treated as project root"),
1515            // Root directory
1516            ("/", true, "Root directory should be outside project"),
1517            // Parent directory references - find_project_path resolves these
1518            (
1519                "project/../other",
1520                true,
1521                "Path with .. that goes outside of root directory",
1522            ),
1523            (
1524                "project/./src/file.rs",
1525                false,
1526                "Path with . should work normally",
1527            ),
1528            // Windows-style paths (if on Windows)
1529            #[cfg(target_os = "windows")]
1530            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1531            #[cfg(target_os = "windows")]
1532            ("project\\src\\main.rs", false, "Windows-style project path"),
1533        ];
1534
1535        for (path, should_confirm, description) in test_cases {
1536            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1537            let auth = cx.update(|cx| {
1538                tool.authorize(
1539                    &EditFileToolInput {
1540                        display_description: "Edit file".into(),
1541                        path: path.into(),
1542                        mode: EditFileMode::Edit,
1543                    },
1544                    &stream_tx,
1545                    cx,
1546                )
1547            });
1548
1549            cx.run_until_parked();
1550
1551            if should_confirm {
1552                stream_rx.expect_authorization().await;
1553            } else {
1554                assert!(
1555                    stream_rx.try_next().is_err(),
1556                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1557                    description,
1558                    path
1559                );
1560                auth.await.unwrap();
1561            }
1562        }
1563    }
1564
1565    #[gpui::test]
1566    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1567        init_test(cx);
1568        let fs = project::FakeFs::new(cx.executor());
1569        fs.insert_tree(
1570            "/project",
1571            json!({
1572                "existing.txt": "content",
1573                ".zed": {
1574                    "settings.json": "{}"
1575                }
1576            }),
1577        )
1578        .await;
1579        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1580        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1581        let context_server_registry =
1582            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1583        let model = Arc::new(FakeLanguageModel::default());
1584        let thread = cx.new(|cx| {
1585            Thread::new(
1586                project.clone(),
1587                cx.new(|_cx| ProjectContext::default()),
1588                context_server_registry.clone(),
1589                Templates::new(),
1590                Some(model.clone()),
1591                cx,
1592            )
1593        });
1594        let tool = Arc::new(EditFileTool::new(
1595            project.clone(),
1596            thread.downgrade(),
1597            language_registry,
1598            Templates::new(),
1599        ));
1600
1601        // Test different EditFileMode values
1602        let modes = vec![
1603            EditFileMode::Edit,
1604            EditFileMode::Create,
1605            EditFileMode::Overwrite,
1606        ];
1607
1608        for mode in modes {
1609            // Test .zed path with different modes
1610            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1611            let _auth = cx.update(|cx| {
1612                tool.authorize(
1613                    &EditFileToolInput {
1614                        display_description: "Edit settings".into(),
1615                        path: "project/.zed/settings.json".into(),
1616                        mode: mode.clone(),
1617                    },
1618                    &stream_tx,
1619                    cx,
1620                )
1621            });
1622
1623            stream_rx.expect_authorization().await;
1624
1625            // Test outside path with different modes
1626            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1627            let _auth = cx.update(|cx| {
1628                tool.authorize(
1629                    &EditFileToolInput {
1630                        display_description: "Edit file".into(),
1631                        path: "/outside/file.txt".into(),
1632                        mode: mode.clone(),
1633                    },
1634                    &stream_tx,
1635                    cx,
1636                )
1637            });
1638
1639            stream_rx.expect_authorization().await;
1640
1641            // Test normal path with different modes
1642            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1643            cx.update(|cx| {
1644                tool.authorize(
1645                    &EditFileToolInput {
1646                        display_description: "Edit file".into(),
1647                        path: "project/normal.txt".into(),
1648                        mode: mode.clone(),
1649                    },
1650                    &stream_tx,
1651                    cx,
1652                )
1653            })
1654            .await
1655            .unwrap();
1656            assert!(stream_rx.try_next().is_err());
1657        }
1658    }
1659
1660    #[gpui::test]
1661    async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1662        init_test(cx);
1663        let fs = project::FakeFs::new(cx.executor());
1664        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1665        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1666        let context_server_registry =
1667            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1668        let model = Arc::new(FakeLanguageModel::default());
1669        let thread = cx.new(|cx| {
1670            Thread::new(
1671                project.clone(),
1672                cx.new(|_cx| ProjectContext::default()),
1673                context_server_registry,
1674                Templates::new(),
1675                Some(model.clone()),
1676                cx,
1677            )
1678        });
1679        let tool = Arc::new(EditFileTool::new(
1680            project,
1681            thread.downgrade(),
1682            language_registry,
1683            Templates::new(),
1684        ));
1685
1686        cx.update(|cx| {
1687            // ...
1688            assert_eq!(
1689                tool.initial_title(
1690                    Err(json!({
1691                        "path": "src/main.rs",
1692                        "display_description": "",
1693                        "old_string": "old code",
1694                        "new_string": "new code"
1695                    })),
1696                    cx
1697                ),
1698                "src/main.rs"
1699            );
1700            assert_eq!(
1701                tool.initial_title(
1702                    Err(json!({
1703                        "path": "",
1704                        "display_description": "Fix error handling",
1705                        "old_string": "old code",
1706                        "new_string": "new code"
1707                    })),
1708                    cx
1709                ),
1710                "Fix error handling"
1711            );
1712            assert_eq!(
1713                tool.initial_title(
1714                    Err(json!({
1715                        "path": "src/main.rs",
1716                        "display_description": "Fix error handling",
1717                        "old_string": "old code",
1718                        "new_string": "new code"
1719                    })),
1720                    cx
1721                ),
1722                "src/main.rs"
1723            );
1724            assert_eq!(
1725                tool.initial_title(
1726                    Err(json!({
1727                        "path": "",
1728                        "display_description": "",
1729                        "old_string": "old code",
1730                        "new_string": "new code"
1731                    })),
1732                    cx
1733                ),
1734                DEFAULT_UI_TEXT
1735            );
1736            assert_eq!(
1737                tool.initial_title(Err(serde_json::Value::Null), cx),
1738                DEFAULT_UI_TEXT
1739            );
1740        });
1741    }
1742
1743    #[gpui::test]
1744    async fn test_diff_finalization(cx: &mut TestAppContext) {
1745        init_test(cx);
1746        let fs = project::FakeFs::new(cx.executor());
1747        fs.insert_tree("/", json!({"main.rs": ""})).await;
1748
1749        let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1750        let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1751        let context_server_registry =
1752            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1753        let model = Arc::new(FakeLanguageModel::default());
1754        let thread = cx.new(|cx| {
1755            Thread::new(
1756                project.clone(),
1757                cx.new(|_cx| ProjectContext::default()),
1758                context_server_registry.clone(),
1759                Templates::new(),
1760                Some(model.clone()),
1761                cx,
1762            )
1763        });
1764
1765        // Ensure the diff is finalized after the edit completes.
1766        {
1767            let tool = Arc::new(EditFileTool::new(
1768                project.clone(),
1769                thread.downgrade(),
1770                languages.clone(),
1771                Templates::new(),
1772            ));
1773            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1774            let edit = cx.update(|cx| {
1775                tool.run(
1776                    EditFileToolInput {
1777                        display_description: "Edit file".into(),
1778                        path: path!("/main.rs").into(),
1779                        mode: EditFileMode::Edit,
1780                    },
1781                    stream_tx,
1782                    cx,
1783                )
1784            });
1785            stream_rx.expect_update_fields().await;
1786            let diff = stream_rx.expect_diff().await;
1787            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1788            cx.run_until_parked();
1789            model.end_last_completion_stream();
1790            edit.await.unwrap();
1791            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1792        }
1793
1794        // Ensure the diff is finalized if an error occurs while editing.
1795        {
1796            model.forbid_requests();
1797            let tool = Arc::new(EditFileTool::new(
1798                project.clone(),
1799                thread.downgrade(),
1800                languages.clone(),
1801                Templates::new(),
1802            ));
1803            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1804            let edit = cx.update(|cx| {
1805                tool.run(
1806                    EditFileToolInput {
1807                        display_description: "Edit file".into(),
1808                        path: path!("/main.rs").into(),
1809                        mode: EditFileMode::Edit,
1810                    },
1811                    stream_tx,
1812                    cx,
1813                )
1814            });
1815            stream_rx.expect_update_fields().await;
1816            let diff = stream_rx.expect_diff().await;
1817            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1818            edit.await.unwrap_err();
1819            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1820            model.allow_requests();
1821        }
1822
1823        // Ensure the diff is finalized if the tool call gets dropped.
1824        {
1825            let tool = Arc::new(EditFileTool::new(
1826                project.clone(),
1827                thread.downgrade(),
1828                languages.clone(),
1829                Templates::new(),
1830            ));
1831            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1832            let edit = cx.update(|cx| {
1833                tool.run(
1834                    EditFileToolInput {
1835                        display_description: "Edit file".into(),
1836                        path: path!("/main.rs").into(),
1837                        mode: EditFileMode::Edit,
1838                    },
1839                    stream_tx,
1840                    cx,
1841                )
1842            });
1843            stream_rx.expect_update_fields().await;
1844            let diff = stream_rx.expect_diff().await;
1845            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1846            drop(edit);
1847            cx.run_until_parked();
1848            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1849        }
1850    }
1851
1852    #[gpui::test]
1853    async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1854        init_test(cx);
1855
1856        let fs = project::FakeFs::new(cx.executor());
1857        fs.insert_tree(
1858            "/root",
1859            json!({
1860                "test.txt": "original content"
1861            }),
1862        )
1863        .await;
1864        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1865        let context_server_registry =
1866            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1867        let model = Arc::new(FakeLanguageModel::default());
1868        let thread = cx.new(|cx| {
1869            Thread::new(
1870                project.clone(),
1871                cx.new(|_cx| ProjectContext::default()),
1872                context_server_registry,
1873                Templates::new(),
1874                Some(model.clone()),
1875                cx,
1876            )
1877        });
1878        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1879
1880        // Initially, file_read_times should be empty
1881        let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1882        assert!(is_empty, "file_read_times should start empty");
1883
1884        // Create read tool
1885        let read_tool = Arc::new(crate::ReadFileTool::new(
1886            thread.downgrade(),
1887            project.clone(),
1888            action_log,
1889        ));
1890
1891        // Read the file to record the read time
1892        cx.update(|cx| {
1893            read_tool.clone().run(
1894                crate::ReadFileToolInput {
1895                    path: "root/test.txt".to_string(),
1896                    start_line: None,
1897                    end_line: None,
1898                },
1899                ToolCallEventStream::test().0,
1900                cx,
1901            )
1902        })
1903        .await
1904        .unwrap();
1905
1906        // Verify that file_read_times now contains an entry for the file
1907        let has_entry = thread.read_with(cx, |thread, _| {
1908            thread.file_read_times.len() == 1
1909                && thread
1910                    .file_read_times
1911                    .keys()
1912                    .any(|path| path.ends_with("test.txt"))
1913        });
1914        assert!(
1915            has_entry,
1916            "file_read_times should contain an entry after reading the file"
1917        );
1918
1919        // Read the file again - should update the entry
1920        cx.update(|cx| {
1921            read_tool.clone().run(
1922                crate::ReadFileToolInput {
1923                    path: "root/test.txt".to_string(),
1924                    start_line: None,
1925                    end_line: None,
1926                },
1927                ToolCallEventStream::test().0,
1928                cx,
1929            )
1930        })
1931        .await
1932        .unwrap();
1933
1934        // Should still have exactly one entry
1935        let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
1936        assert!(
1937            has_one_entry,
1938            "file_read_times should still have one entry after re-reading"
1939        );
1940    }
1941
1942    fn init_test(cx: &mut TestAppContext) {
1943        cx.update(|cx| {
1944            let settings_store = SettingsStore::test(cx);
1945            cx.set_global(settings_store);
1946        });
1947    }
1948
1949    #[gpui::test]
1950    async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
1951        init_test(cx);
1952
1953        let fs = project::FakeFs::new(cx.executor());
1954        fs.insert_tree(
1955            "/root",
1956            json!({
1957                "test.txt": "original content"
1958            }),
1959        )
1960        .await;
1961        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1962        let context_server_registry =
1963            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1964        let model = Arc::new(FakeLanguageModel::default());
1965        let thread = cx.new(|cx| {
1966            Thread::new(
1967                project.clone(),
1968                cx.new(|_cx| ProjectContext::default()),
1969                context_server_registry,
1970                Templates::new(),
1971                Some(model.clone()),
1972                cx,
1973            )
1974        });
1975        let languages = project.read_with(cx, |project, _| project.languages().clone());
1976        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1977
1978        let read_tool = Arc::new(crate::ReadFileTool::new(
1979            thread.downgrade(),
1980            project.clone(),
1981            action_log,
1982        ));
1983        let edit_tool = Arc::new(EditFileTool::new(
1984            project.clone(),
1985            thread.downgrade(),
1986            languages,
1987            Templates::new(),
1988        ));
1989
1990        // Read the file first
1991        cx.update(|cx| {
1992            read_tool.clone().run(
1993                crate::ReadFileToolInput {
1994                    path: "root/test.txt".to_string(),
1995                    start_line: None,
1996                    end_line: None,
1997                },
1998                ToolCallEventStream::test().0,
1999                cx,
2000            )
2001        })
2002        .await
2003        .unwrap();
2004
2005        // First edit should work
2006        let edit_result = {
2007            let edit_task = cx.update(|cx| {
2008                edit_tool.clone().run(
2009                    EditFileToolInput {
2010                        display_description: "First edit".into(),
2011                        path: "root/test.txt".into(),
2012                        mode: EditFileMode::Edit,
2013                    },
2014                    ToolCallEventStream::test().0,
2015                    cx,
2016                )
2017            });
2018
2019            cx.executor().run_until_parked();
2020            model.send_last_completion_stream_text_chunk(
2021                "<old_text>original content</old_text><new_text>modified content</new_text>"
2022                    .to_string(),
2023            );
2024            model.end_last_completion_stream();
2025
2026            edit_task.await
2027        };
2028        assert!(
2029            edit_result.is_ok(),
2030            "First edit should succeed, got error: {:?}",
2031            edit_result.as_ref().err()
2032        );
2033
2034        // Second edit should also work because the edit updated the recorded read time
2035        let edit_result = {
2036            let edit_task = cx.update(|cx| {
2037                edit_tool.clone().run(
2038                    EditFileToolInput {
2039                        display_description: "Second edit".into(),
2040                        path: "root/test.txt".into(),
2041                        mode: EditFileMode::Edit,
2042                    },
2043                    ToolCallEventStream::test().0,
2044                    cx,
2045                )
2046            });
2047
2048            cx.executor().run_until_parked();
2049            model.send_last_completion_stream_text_chunk(
2050                "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2051            );
2052            model.end_last_completion_stream();
2053
2054            edit_task.await
2055        };
2056        assert!(
2057            edit_result.is_ok(),
2058            "Second consecutive edit should succeed, got error: {:?}",
2059            edit_result.as_ref().err()
2060        );
2061    }
2062
2063    #[gpui::test]
2064    async fn test_external_modification_detected(cx: &mut TestAppContext) {
2065        init_test(cx);
2066
2067        let fs = project::FakeFs::new(cx.executor());
2068        fs.insert_tree(
2069            "/root",
2070            json!({
2071                "test.txt": "original content"
2072            }),
2073        )
2074        .await;
2075        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2076        let context_server_registry =
2077            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2078        let model = Arc::new(FakeLanguageModel::default());
2079        let thread = cx.new(|cx| {
2080            Thread::new(
2081                project.clone(),
2082                cx.new(|_cx| ProjectContext::default()),
2083                context_server_registry,
2084                Templates::new(),
2085                Some(model.clone()),
2086                cx,
2087            )
2088        });
2089        let languages = project.read_with(cx, |project, _| project.languages().clone());
2090        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2091
2092        let read_tool = Arc::new(crate::ReadFileTool::new(
2093            thread.downgrade(),
2094            project.clone(),
2095            action_log,
2096        ));
2097        let edit_tool = Arc::new(EditFileTool::new(
2098            project.clone(),
2099            thread.downgrade(),
2100            languages,
2101            Templates::new(),
2102        ));
2103
2104        // Read the file first
2105        cx.update(|cx| {
2106            read_tool.clone().run(
2107                crate::ReadFileToolInput {
2108                    path: "root/test.txt".to_string(),
2109                    start_line: None,
2110                    end_line: None,
2111                },
2112                ToolCallEventStream::test().0,
2113                cx,
2114            )
2115        })
2116        .await
2117        .unwrap();
2118
2119        // Simulate external modification - advance time and save file
2120        cx.background_executor
2121            .advance_clock(std::time::Duration::from_secs(2));
2122        fs.save(
2123            path!("/root/test.txt").as_ref(),
2124            &"externally modified content".into(),
2125            language::LineEnding::Unix,
2126        )
2127        .await
2128        .unwrap();
2129
2130        // Reload the buffer to pick up the new mtime
2131        let project_path = project
2132            .read_with(cx, |project, cx| {
2133                project.find_project_path("root/test.txt", cx)
2134            })
2135            .expect("Should find project path");
2136        let buffer = project
2137            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2138            .await
2139            .unwrap();
2140        buffer
2141            .update(cx, |buffer, cx| buffer.reload(cx))
2142            .await
2143            .unwrap();
2144
2145        cx.executor().run_until_parked();
2146
2147        // Try to edit - should fail because file was modified externally
2148        let result = cx
2149            .update(|cx| {
2150                edit_tool.clone().run(
2151                    EditFileToolInput {
2152                        display_description: "Edit after external change".into(),
2153                        path: "root/test.txt".into(),
2154                        mode: EditFileMode::Edit,
2155                    },
2156                    ToolCallEventStream::test().0,
2157                    cx,
2158                )
2159            })
2160            .await;
2161
2162        assert!(
2163            result.is_err(),
2164            "Edit should fail after external modification"
2165        );
2166        let error_msg = result.unwrap_err().to_string();
2167        assert!(
2168            error_msg.contains("has been modified since you last read it"),
2169            "Error should mention file modification, got: {}",
2170            error_msg
2171        );
2172    }
2173
2174    #[gpui::test]
2175    async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2176        init_test(cx);
2177
2178        let fs = project::FakeFs::new(cx.executor());
2179        fs.insert_tree(
2180            "/root",
2181            json!({
2182                "test.txt": "original content"
2183            }),
2184        )
2185        .await;
2186        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2187        let context_server_registry =
2188            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2189        let model = Arc::new(FakeLanguageModel::default());
2190        let thread = cx.new(|cx| {
2191            Thread::new(
2192                project.clone(),
2193                cx.new(|_cx| ProjectContext::default()),
2194                context_server_registry,
2195                Templates::new(),
2196                Some(model.clone()),
2197                cx,
2198            )
2199        });
2200        let languages = project.read_with(cx, |project, _| project.languages().clone());
2201        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2202
2203        let read_tool = Arc::new(crate::ReadFileTool::new(
2204            thread.downgrade(),
2205            project.clone(),
2206            action_log,
2207        ));
2208        let edit_tool = Arc::new(EditFileTool::new(
2209            project.clone(),
2210            thread.downgrade(),
2211            languages,
2212            Templates::new(),
2213        ));
2214
2215        // Read the file first
2216        cx.update(|cx| {
2217            read_tool.clone().run(
2218                crate::ReadFileToolInput {
2219                    path: "root/test.txt".to_string(),
2220                    start_line: None,
2221                    end_line: None,
2222                },
2223                ToolCallEventStream::test().0,
2224                cx,
2225            )
2226        })
2227        .await
2228        .unwrap();
2229
2230        // Open the buffer and make it dirty by editing without saving
2231        let project_path = project
2232            .read_with(cx, |project, cx| {
2233                project.find_project_path("root/test.txt", cx)
2234            })
2235            .expect("Should find project path");
2236        let buffer = project
2237            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2238            .await
2239            .unwrap();
2240
2241        // Make an in-memory edit to the buffer (making it dirty)
2242        buffer.update(cx, |buffer, cx| {
2243            let end_point = buffer.max_point();
2244            buffer.edit([(end_point..end_point, " added text")], None, cx);
2245        });
2246
2247        // Verify buffer is dirty
2248        let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2249        assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2250
2251        // Try to edit - should fail because buffer has unsaved changes
2252        let result = cx
2253            .update(|cx| {
2254                edit_tool.clone().run(
2255                    EditFileToolInput {
2256                        display_description: "Edit with dirty buffer".into(),
2257                        path: "root/test.txt".into(),
2258                        mode: EditFileMode::Edit,
2259                    },
2260                    ToolCallEventStream::test().0,
2261                    cx,
2262                )
2263            })
2264            .await;
2265
2266        assert!(result.is_err(), "Edit should fail when buffer is dirty");
2267        let error_msg = result.unwrap_err().to_string();
2268        assert!(
2269            error_msg.contains("This file has unsaved changes."),
2270            "Error should mention unsaved changes, got: {}",
2271            error_msg
2272        );
2273        assert!(
2274            error_msg.contains("keep or discard"),
2275            "Error should ask whether to keep or discard changes, got: {}",
2276            error_msg
2277        );
2278        // Since save_file and restore_file_from_disk tools aren't added to the thread,
2279        // the error message should ask the user to manually save or revert
2280        assert!(
2281            error_msg.contains("save or revert the file manually"),
2282            "Error should ask user to manually save or revert when tools aren't available, got: {}",
2283            error_msg
2284        );
2285    }
2286}