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