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