edit_file_tool.rs

   1use crate::{AgentTool, Thread, ToolCallEventStream};
   2use acp_thread::Diff;
   3use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
   4use anyhow::{Context as _, Result, anyhow};
   5use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat};
   6use cloud_llm_client::CompletionIntent;
   7use collections::HashSet;
   8use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
   9use indoc::formatdoc;
  10use language::language_settings::{self, FormatOnSave};
  11use language::{LanguageRegistry, ToPoint};
  12use language_model::LanguageModelToolResultContent;
  13use paths;
  14use project::lsp_store::{FormatTrigger, LspFormatTarget};
  15use project::{Project, ProjectPath};
  16use schemars::JsonSchema;
  17use serde::{Deserialize, Serialize};
  18use settings::Settings;
  19use smol::stream::StreamExt as _;
  20use std::path::{Path, PathBuf};
  21use std::sync::Arc;
  22use ui::SharedString;
  23use util::ResultExt;
  24
  25const DEFAULT_UI_TEXT: &str = "Editing file";
  26
  27/// 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.
  28///
  29/// Before using this tool:
  30///
  31/// 1. Use the `read_file` tool to understand the file's contents and context
  32///
  33/// 2. Verify the directory path is correct (only applicable when creating new files):
  34///    - Use the `list_directory` tool to verify the parent directory exists and is the correct location
  35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  36pub struct EditFileToolInput {
  37    /// A one-line, user-friendly markdown description of the edit. This will be
  38    /// shown in the UI and also passed to another model to perform the edit.
  39    ///
  40    /// Be terse, but also descriptive in what you want to achieve with this
  41    /// edit. Avoid generic instructions.
  42    ///
  43    /// NEVER mention the file path in this description.
  44    ///
  45    /// <example>Fix API endpoint URLs</example>
  46    /// <example>Update copyright year in `page_footer`</example>
  47    ///
  48    /// Make sure to include this field before all the others in the input object
  49    /// so that we can display it immediately.
  50    pub display_description: String,
  51
  52    /// The full path of the file to create or modify in the project.
  53    ///
  54    /// WARNING: When specifying which file path need changing, you MUST
  55    /// start each path with one of the project's root directories.
  56    ///
  57    /// The following examples assume we have two root directories in the project:
  58    /// - /a/b/backend
  59    /// - /c/d/frontend
  60    ///
  61    /// <example>
  62    /// `backend/src/main.rs`
  63    ///
  64    /// Notice how the file path starts with `backend`. Without that, the path
  65    /// would be ambiguous and the call would fail!
  66    /// </example>
  67    ///
  68    /// <example>
  69    /// `frontend/db.js`
  70    /// </example>
  71    pub path: PathBuf,
  72
  73    /// The mode of operation on the file. Possible values:
  74    /// - 'edit': Make granular edits to an existing file.
  75    /// - 'create': Create a new file if it doesn't exist.
  76    /// - 'overwrite': Replace the entire contents of an existing file.
  77    ///
  78    /// When a file already exists or you just created it, prefer editing
  79    /// it as opposed to recreating it from scratch.
  80    pub mode: EditFileMode,
  81}
  82
  83#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  84struct EditFileToolPartialInput {
  85    #[serde(default)]
  86    path: String,
  87    #[serde(default)]
  88    display_description: String,
  89}
  90
  91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  92#[serde(rename_all = "lowercase")]
  93pub enum EditFileMode {
  94    Edit,
  95    Create,
  96    Overwrite,
  97}
  98
  99#[derive(Debug, Serialize, Deserialize)]
 100pub struct EditFileToolOutput {
 101    #[serde(alias = "original_path")]
 102    input_path: PathBuf,
 103    new_text: String,
 104    old_text: Arc<String>,
 105    #[serde(default)]
 106    diff: String,
 107    #[serde(alias = "raw_output")]
 108    edit_agent_output: EditAgentOutput,
 109}
 110
 111impl From<EditFileToolOutput> for LanguageModelToolResultContent {
 112    fn from(output: EditFileToolOutput) -> Self {
 113        if output.diff.is_empty() {
 114            "No edits were made.".into()
 115        } else {
 116            format!(
 117                "Edited {}:\n\n```diff\n{}\n```",
 118                output.input_path.display(),
 119                output.diff
 120            )
 121            .into()
 122        }
 123    }
 124}
 125
 126pub struct EditFileTool {
 127    thread: WeakEntity<Thread>,
 128    language_registry: Arc<LanguageRegistry>,
 129}
 130
 131impl EditFileTool {
 132    pub fn new(thread: WeakEntity<Thread>, language_registry: Arc<LanguageRegistry>) -> Self {
 133        Self {
 134            thread,
 135            language_registry,
 136        }
 137    }
 138
 139    fn authorize(
 140        &self,
 141        input: &EditFileToolInput,
 142        event_stream: &ToolCallEventStream,
 143        cx: &mut App,
 144    ) -> Task<Result<()>> {
 145        if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
 146            return Task::ready(Ok(()));
 147        }
 148
 149        // If any path component matches the local settings folder, then this could affect
 150        // the editor in ways beyond the project source, so prompt.
 151        let local_settings_folder = paths::local_settings_folder_relative_path();
 152        let path = Path::new(&input.path);
 153        if path
 154            .components()
 155            .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
 156        {
 157            return event_stream.authorize(
 158                format!("{} (local settings)", input.display_description),
 159                cx,
 160            );
 161        }
 162
 163        // It's also possible that the global config dir is configured to be inside the project,
 164        // so check for that edge case too.
 165        if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
 166            if canonical_path.starts_with(paths::config_dir()) {
 167                return event_stream.authorize(
 168                    format!("{} (global settings)", input.display_description),
 169                    cx,
 170                );
 171            }
 172        }
 173
 174        // Check if path is inside the global config directory
 175        // First check if it's already inside project - if not, try to canonicalize
 176        let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {
 177            thread.project().read(cx).find_project_path(&input.path, cx)
 178        }) else {
 179            return Task::ready(Err(anyhow!("thread was dropped")));
 180        };
 181
 182        // If the path is inside the project, and it's not one of the above edge cases,
 183        // then no confirmation is necessary. Otherwise, confirmation is necessary.
 184        if project_path.is_some() {
 185            Task::ready(Ok(()))
 186        } else {
 187            event_stream.authorize(&input.display_description, cx)
 188        }
 189    }
 190}
 191
 192impl AgentTool for EditFileTool {
 193    type Input = EditFileToolInput;
 194    type Output = EditFileToolOutput;
 195
 196    fn name(&self) -> SharedString {
 197        "edit_file".into()
 198    }
 199
 200    fn kind(&self) -> acp::ToolKind {
 201        acp::ToolKind::Edit
 202    }
 203
 204    fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
 205        match input {
 206            Ok(input) => input.display_description.into(),
 207            Err(raw_input) => {
 208                if let Some(input) =
 209                    serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
 210                {
 211                    let description = input.display_description.trim();
 212                    if !description.is_empty() {
 213                        return description.to_string().into();
 214                    }
 215
 216                    let path = input.path.trim().to_string();
 217                    if !path.is_empty() {
 218                        return path.into();
 219                    }
 220                }
 221
 222                DEFAULT_UI_TEXT.into()
 223            }
 224        }
 225    }
 226
 227    fn run(
 228        self: Arc<Self>,
 229        input: Self::Input,
 230        event_stream: ToolCallEventStream,
 231        cx: &mut App,
 232    ) -> Task<Result<Self::Output>> {
 233        let Ok(project) = self
 234            .thread
 235            .read_with(cx, |thread, _cx| thread.project().clone())
 236        else {
 237            return Task::ready(Err(anyhow!("thread was dropped")));
 238        };
 239        let project_path = match resolve_path(&input, project.clone(), cx) {
 240            Ok(path) => path,
 241            Err(err) => return Task::ready(Err(anyhow!(err))),
 242        };
 243        let abs_path = project.read(cx).absolute_path(&project_path, cx);
 244        if let Some(abs_path) = abs_path.clone() {
 245            event_stream.update_fields(ToolCallUpdateFields {
 246                locations: Some(vec![acp::ToolCallLocation {
 247                    path: abs_path,
 248                    line: None,
 249                }]),
 250                ..Default::default()
 251            });
 252        }
 253
 254        let authorize = self.authorize(&input, &event_stream, cx);
 255        cx.spawn(async move |cx: &mut AsyncApp| {
 256            authorize.await?;
 257
 258            let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
 259                let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
 260                (request, thread.model().clone(), thread.action_log().clone())
 261            })?;
 262
 263            let edit_format = EditFormat::from_model(model.clone())?;
 264            let edit_agent = EditAgent::new(
 265                model,
 266                project.clone(),
 267                action_log.clone(),
 268                // TODO: move edit agent to this crate so we can use our templates
 269                assistant_tools::templates::Templates::new(),
 270                edit_format,
 271            );
 272
 273            let buffer = project
 274                .update(cx, |project, cx| {
 275                    project.open_buffer(project_path.clone(), cx)
 276                })?
 277                .await?;
 278
 279            let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
 280            event_stream.update_diff(diff.clone());
 281
 282            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 283            let old_text = cx
 284                .background_spawn({
 285                    let old_snapshot = old_snapshot.clone();
 286                    async move { Arc::new(old_snapshot.text()) }
 287                })
 288                .await;
 289
 290
 291            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 292                edit_agent.edit(
 293                    buffer.clone(),
 294                    input.display_description.clone(),
 295                    &request,
 296                    cx,
 297                )
 298            } else {
 299                edit_agent.overwrite(
 300                    buffer.clone(),
 301                    input.display_description.clone(),
 302                    &request,
 303                    cx,
 304                )
 305            };
 306
 307            let mut hallucinated_old_text = false;
 308            let mut ambiguous_ranges = Vec::new();
 309            let mut emitted_location = false;
 310            while let Some(event) = events.next().await {
 311                match event {
 312                    EditAgentOutputEvent::Edited(range) => {
 313                        if !emitted_location {
 314                            let line = buffer.update(cx, |buffer, _cx| {
 315                                range.start.to_point(&buffer.snapshot()).row
 316                            }).ok();
 317                            if let Some(abs_path) = abs_path.clone() {
 318                                event_stream.update_fields(ToolCallUpdateFields {
 319                                    locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
 320                                    ..Default::default()
 321                                });
 322                            }
 323                            emitted_location = true;
 324                        }
 325                    },
 326                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
 327                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
 328                    EditAgentOutputEvent::ResolvingEditRange(range) => {
 329                        diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx))?;
 330                        // if !emitted_location {
 331                        //     let line = buffer.update(cx, |buffer, _cx| {
 332                        //         range.start.to_point(&buffer.snapshot()).row
 333                        //     }).ok();
 334                        //     if let Some(abs_path) = abs_path.clone() {
 335                        //         event_stream.update_fields(ToolCallUpdateFields {
 336                        //             locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
 337                        //             ..Default::default()
 338                        //         });
 339                        //     }
 340                        // }
 341                    }
 342                }
 343            }
 344
 345            // If format_on_save is enabled, format the buffer
 346            let format_on_save_enabled = buffer
 347                .read_with(cx, |buffer, cx| {
 348                    let settings = language_settings::language_settings(
 349                        buffer.language().map(|l| l.name()),
 350                        buffer.file(),
 351                        cx,
 352                    );
 353                    settings.format_on_save != FormatOnSave::Off
 354                })
 355                .unwrap_or(false);
 356
 357            let edit_agent_output = output.await?;
 358
 359            if format_on_save_enabled {
 360                action_log.update(cx, |log, cx| {
 361                    log.buffer_edited(buffer.clone(), cx);
 362                })?;
 363
 364                let format_task = project.update(cx, |project, cx| {
 365                    project.format(
 366                        HashSet::from_iter([buffer.clone()]),
 367                        LspFormatTarget::Buffers,
 368                        false, // Don't push to history since the tool did it.
 369                        FormatTrigger::Save,
 370                        cx,
 371                    )
 372                })?;
 373                format_task.await.log_err();
 374            }
 375
 376            project
 377                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
 378                .await?;
 379
 380            action_log.update(cx, |log, cx| {
 381                log.buffer_edited(buffer.clone(), cx);
 382            })?;
 383
 384            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 385            let (new_text, unified_diff) = cx
 386                .background_spawn({
 387                    let new_snapshot = new_snapshot.clone();
 388                    let old_text = old_text.clone();
 389                    async move {
 390                        let new_text = new_snapshot.text();
 391                        let diff = language::unified_diff(&old_text, &new_text);
 392                        (new_text, diff)
 393                    }
 394                })
 395                .await;
 396
 397            diff.update(cx, |diff, cx| diff.finalize(cx)).ok();
 398
 399            let input_path = input.path.display();
 400            if unified_diff.is_empty() {
 401                anyhow::ensure!(
 402                    !hallucinated_old_text,
 403                    formatdoc! {"
 404                        Some edits were produced but none of them could be applied.
 405                        Read the relevant sections of {input_path} again so that
 406                        I can perform the requested edits.
 407                    "}
 408                );
 409                anyhow::ensure!(
 410                    ambiguous_ranges.is_empty(),
 411                    {
 412                        let line_numbers = ambiguous_ranges
 413                            .iter()
 414                            .map(|range| range.start.to_string())
 415                            .collect::<Vec<_>>()
 416                            .join(", ");
 417                        formatdoc! {"
 418                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
 419                            relevant sections of {input_path} again and extend <old_text> so
 420                            that I can perform the requested edits.
 421                        "}
 422                    }
 423                );
 424            }
 425
 426            Ok(EditFileToolOutput {
 427                input_path: input.path,
 428                new_text: new_text.clone(),
 429                old_text,
 430                diff: unified_diff,
 431                edit_agent_output,
 432            })
 433        })
 434    }
 435
 436    fn replay(
 437        &self,
 438        _input: Self::Input,
 439        output: Self::Output,
 440        event_stream: ToolCallEventStream,
 441        cx: &mut App,
 442    ) -> Result<()> {
 443        event_stream.update_diff(cx.new(|cx| {
 444            Diff::finalized(
 445                output.input_path,
 446                Some(output.old_text.to_string()),
 447                output.new_text,
 448                self.language_registry.clone(),
 449                cx,
 450            )
 451        }));
 452        Ok(())
 453    }
 454}
 455
 456/// Validate that the file path is valid, meaning:
 457///
 458/// - For `edit` and `overwrite`, the path must point to an existing file.
 459/// - For `create`, the file must not already exist, but it's parent dir must exist.
 460fn resolve_path(
 461    input: &EditFileToolInput,
 462    project: Entity<Project>,
 463    cx: &mut App,
 464) -> Result<ProjectPath> {
 465    let project = project.read(cx);
 466
 467    match input.mode {
 468        EditFileMode::Edit | EditFileMode::Overwrite => {
 469            let path = project
 470                .find_project_path(&input.path, cx)
 471                .context("Can't edit file: path not found")?;
 472
 473            let entry = project
 474                .entry_for_path(&path, cx)
 475                .context("Can't edit file: path not found")?;
 476
 477            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 478            Ok(path)
 479        }
 480
 481        EditFileMode::Create => {
 482            if let Some(path) = project.find_project_path(&input.path, cx) {
 483                anyhow::ensure!(
 484                    project.entry_for_path(&path, cx).is_none(),
 485                    "Can't create file: file already exists"
 486                );
 487            }
 488
 489            let parent_path = input
 490                .path
 491                .parent()
 492                .context("Can't create file: incorrect path")?;
 493
 494            let parent_project_path = project.find_project_path(&parent_path, cx);
 495
 496            let parent_entry = parent_project_path
 497                .as_ref()
 498                .and_then(|path| project.entry_for_path(&path, cx))
 499                .context("Can't create file: parent directory doesn't exist")?;
 500
 501            anyhow::ensure!(
 502                parent_entry.is_dir(),
 503                "Can't create file: parent is not a directory"
 504            );
 505
 506            let file_name = input
 507                .path
 508                .file_name()
 509                .context("Can't create file: invalid filename")?;
 510
 511            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 512                path: Arc::from(parent.path.join(file_name)),
 513                ..parent
 514            });
 515
 516            new_file_path.context("Can't create file")
 517        }
 518    }
 519}
 520
 521#[cfg(test)]
 522mod tests {
 523    use super::*;
 524    use crate::{ContextServerRegistry, Templates, generate_session_id};
 525    use action_log::ActionLog;
 526    use client::TelemetrySettings;
 527    use fs::Fs;
 528    use gpui::{TestAppContext, UpdateGlobal};
 529    use language_model::fake_provider::FakeLanguageModel;
 530    use serde_json::json;
 531    use settings::SettingsStore;
 532    use std::rc::Rc;
 533    use util::path;
 534
 535    #[gpui::test]
 536    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 537        init_test(cx);
 538
 539        let fs = project::FakeFs::new(cx.executor());
 540        fs.insert_tree("/root", json!({})).await;
 541        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 542        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
 543        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 544        let context_server_registry =
 545            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 546        let model = Arc::new(FakeLanguageModel::default());
 547        let thread = cx.new(|cx| {
 548            Thread::new(
 549                generate_session_id(),
 550                project,
 551                Rc::default(),
 552                context_server_registry,
 553                action_log,
 554                Templates::new(),
 555                model,
 556                cx,
 557            )
 558        });
 559        let result = cx
 560            .update(|cx| {
 561                let input = EditFileToolInput {
 562                    display_description: "Some edit".into(),
 563                    path: "root/nonexistent_file.txt".into(),
 564                    mode: EditFileMode::Edit,
 565                };
 566                Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
 567                    input,
 568                    ToolCallEventStream::test().0,
 569                    cx,
 570                )
 571            })
 572            .await;
 573        assert_eq!(
 574            result.unwrap_err().to_string(),
 575            "Can't edit file: path not found"
 576        );
 577    }
 578
 579    #[gpui::test]
 580    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
 581        let mode = &EditFileMode::Create;
 582
 583        let result = test_resolve_path(mode, "root/new.txt", cx);
 584        assert_resolved_path_eq(result.await, "new.txt");
 585
 586        let result = test_resolve_path(mode, "new.txt", cx);
 587        assert_resolved_path_eq(result.await, "new.txt");
 588
 589        let result = test_resolve_path(mode, "dir/new.txt", cx);
 590        assert_resolved_path_eq(result.await, "dir/new.txt");
 591
 592        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
 593        assert_eq!(
 594            result.await.unwrap_err().to_string(),
 595            "Can't create file: file already exists"
 596        );
 597
 598        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
 599        assert_eq!(
 600            result.await.unwrap_err().to_string(),
 601            "Can't create file: parent directory doesn't exist"
 602        );
 603    }
 604
 605    #[gpui::test]
 606    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
 607        let mode = &EditFileMode::Edit;
 608
 609        let path_with_root = "root/dir/subdir/existing.txt";
 610        let path_without_root = "dir/subdir/existing.txt";
 611        let result = test_resolve_path(mode, path_with_root, cx);
 612        assert_resolved_path_eq(result.await, path_without_root);
 613
 614        let result = test_resolve_path(mode, path_without_root, cx);
 615        assert_resolved_path_eq(result.await, path_without_root);
 616
 617        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
 618        assert_eq!(
 619            result.await.unwrap_err().to_string(),
 620            "Can't edit file: path not found"
 621        );
 622
 623        let result = test_resolve_path(mode, "root/dir", cx);
 624        assert_eq!(
 625            result.await.unwrap_err().to_string(),
 626            "Can't edit file: path is a directory"
 627        );
 628    }
 629
 630    async fn test_resolve_path(
 631        mode: &EditFileMode,
 632        path: &str,
 633        cx: &mut TestAppContext,
 634    ) -> anyhow::Result<ProjectPath> {
 635        init_test(cx);
 636
 637        let fs = project::FakeFs::new(cx.executor());
 638        fs.insert_tree(
 639            "/root",
 640            json!({
 641                "dir": {
 642                    "subdir": {
 643                        "existing.txt": "hello"
 644                    }
 645                }
 646            }),
 647        )
 648        .await;
 649        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 650
 651        let input = EditFileToolInput {
 652            display_description: "Some edit".into(),
 653            path: path.into(),
 654            mode: mode.clone(),
 655        };
 656
 657        let result = cx.update(|cx| resolve_path(&input, project, cx));
 658        result
 659    }
 660
 661    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
 662        let actual = path
 663            .expect("Should return valid path")
 664            .path
 665            .to_str()
 666            .unwrap()
 667            .replace("\\", "/"); // Naive Windows paths normalization
 668        assert_eq!(actual, expected);
 669    }
 670
 671    #[gpui::test]
 672    async fn test_format_on_save(cx: &mut TestAppContext) {
 673        init_test(cx);
 674
 675        let fs = project::FakeFs::new(cx.executor());
 676        fs.insert_tree("/root", json!({"src": {}})).await;
 677
 678        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 679
 680        // Set up a Rust language with LSP formatting support
 681        let rust_language = Arc::new(language::Language::new(
 682            language::LanguageConfig {
 683                name: "Rust".into(),
 684                matcher: language::LanguageMatcher {
 685                    path_suffixes: vec!["rs".to_string()],
 686                    ..Default::default()
 687                },
 688                ..Default::default()
 689            },
 690            None,
 691        ));
 692
 693        // Register the language and fake LSP
 694        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 695        language_registry.add(rust_language);
 696
 697        let mut fake_language_servers = language_registry.register_fake_lsp(
 698            "Rust",
 699            language::FakeLspAdapter {
 700                capabilities: lsp::ServerCapabilities {
 701                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
 702                    ..Default::default()
 703                },
 704                ..Default::default()
 705            },
 706        );
 707
 708        // Create the file
 709        fs.save(
 710            path!("/root/src/main.rs").as_ref(),
 711            &"initial content".into(),
 712            language::LineEnding::Unix,
 713        )
 714        .await
 715        .unwrap();
 716
 717        // Open the buffer to trigger LSP initialization
 718        let buffer = project
 719            .update(cx, |project, cx| {
 720                project.open_local_buffer(path!("/root/src/main.rs"), cx)
 721            })
 722            .await
 723            .unwrap();
 724
 725        // Register the buffer with language servers
 726        let _handle = project.update(cx, |project, cx| {
 727            project.register_buffer_with_language_servers(&buffer, cx)
 728        });
 729
 730        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
 731        const FORMATTED_CONTENT: &str =
 732            "This file was formatted by the fake formatter in the test.\n";
 733
 734        // Get the fake language server and set up formatting handler
 735        let fake_language_server = fake_language_servers.next().await.unwrap();
 736        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
 737            |_, _| async move {
 738                Ok(Some(vec![lsp::TextEdit {
 739                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
 740                    new_text: FORMATTED_CONTENT.to_string(),
 741                }]))
 742            }
 743        });
 744
 745        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 746        let context_server_registry =
 747            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 748        let model = Arc::new(FakeLanguageModel::default());
 749        let thread = cx.new(|cx| {
 750            Thread::new(
 751                generate_session_id(),
 752                project,
 753                Rc::default(),
 754                context_server_registry,
 755                action_log.clone(),
 756                Templates::new(),
 757                model.clone(),
 758                cx,
 759            )
 760        });
 761
 762        // First, test with format_on_save enabled
 763        cx.update(|cx| {
 764            SettingsStore::update_global(cx, |store, cx| {
 765                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
 766                    cx,
 767                    |settings| {
 768                        settings.defaults.format_on_save = Some(FormatOnSave::On);
 769                        settings.defaults.formatter =
 770                            Some(language::language_settings::SelectedFormatter::Auto);
 771                    },
 772                );
 773            });
 774        });
 775
 776        // Have the model stream unformatted content
 777        let edit_result = {
 778            let edit_task = cx.update(|cx| {
 779                let input = EditFileToolInput {
 780                    display_description: "Create main function".into(),
 781                    path: "root/src/main.rs".into(),
 782                    mode: EditFileMode::Overwrite,
 783                };
 784                Arc::new(EditFileTool::new(
 785                    thread.downgrade(),
 786                    language_registry.clone(),
 787                ))
 788                .run(input, ToolCallEventStream::test().0, cx)
 789            });
 790
 791            // Stream the unformatted content
 792            cx.executor().run_until_parked();
 793            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 794            model.end_last_completion_stream();
 795
 796            edit_task.await
 797        };
 798        assert!(edit_result.is_ok());
 799
 800        // Wait for any async operations (e.g. formatting) to complete
 801        cx.executor().run_until_parked();
 802
 803        // Read the file to verify it was formatted automatically
 804        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 805        assert_eq!(
 806            // Ignore carriage returns on Windows
 807            new_content.replace("\r\n", "\n"),
 808            FORMATTED_CONTENT,
 809            "Code should be formatted when format_on_save is enabled"
 810        );
 811
 812        let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
 813
 814        assert_eq!(
 815            stale_buffer_count, 0,
 816            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
 817             This causes the agent to think the file was modified externally when it was just formatted.",
 818            stale_buffer_count
 819        );
 820
 821        // Next, test with format_on_save disabled
 822        cx.update(|cx| {
 823            SettingsStore::update_global(cx, |store, cx| {
 824                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
 825                    cx,
 826                    |settings| {
 827                        settings.defaults.format_on_save = Some(FormatOnSave::Off);
 828                    },
 829                );
 830            });
 831        });
 832
 833        // Stream unformatted edits again
 834        let edit_result = {
 835            let edit_task = cx.update(|cx| {
 836                let input = EditFileToolInput {
 837                    display_description: "Update main function".into(),
 838                    path: "root/src/main.rs".into(),
 839                    mode: EditFileMode::Overwrite,
 840                };
 841                Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
 842                    input,
 843                    ToolCallEventStream::test().0,
 844                    cx,
 845                )
 846            });
 847
 848            // Stream the unformatted content
 849            cx.executor().run_until_parked();
 850            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 851            model.end_last_completion_stream();
 852
 853            edit_task.await
 854        };
 855        assert!(edit_result.is_ok());
 856
 857        // Wait for any async operations (e.g. formatting) to complete
 858        cx.executor().run_until_parked();
 859
 860        // Verify the file was not formatted
 861        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 862        assert_eq!(
 863            // Ignore carriage returns on Windows
 864            new_content.replace("\r\n", "\n"),
 865            UNFORMATTED_CONTENT,
 866            "Code should not be formatted when format_on_save is disabled"
 867        );
 868    }
 869
 870    #[gpui::test]
 871    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
 872        init_test(cx);
 873
 874        let fs = project::FakeFs::new(cx.executor());
 875        fs.insert_tree("/root", json!({"src": {}})).await;
 876
 877        // Create a simple file with trailing whitespace
 878        fs.save(
 879            path!("/root/src/main.rs").as_ref(),
 880            &"initial content".into(),
 881            language::LineEnding::Unix,
 882        )
 883        .await
 884        .unwrap();
 885
 886        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 887        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
 888        let context_server_registry =
 889            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 890        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 891        let model = Arc::new(FakeLanguageModel::default());
 892        let thread = cx.new(|cx| {
 893            Thread::new(
 894                generate_session_id(),
 895                project,
 896                Rc::default(),
 897                context_server_registry,
 898                action_log.clone(),
 899                Templates::new(),
 900                model.clone(),
 901                cx,
 902            )
 903        });
 904
 905        // First, test with remove_trailing_whitespace_on_save enabled
 906        cx.update(|cx| {
 907            SettingsStore::update_global(cx, |store, cx| {
 908                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
 909                    cx,
 910                    |settings| {
 911                        settings.defaults.remove_trailing_whitespace_on_save = Some(true);
 912                    },
 913                );
 914            });
 915        });
 916
 917        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
 918            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
 919
 920        // Have the model stream content that contains trailing whitespace
 921        let edit_result = {
 922            let edit_task = cx.update(|cx| {
 923                let input = EditFileToolInput {
 924                    display_description: "Create main function".into(),
 925                    path: "root/src/main.rs".into(),
 926                    mode: EditFileMode::Overwrite,
 927                };
 928                Arc::new(EditFileTool::new(
 929                    thread.downgrade(),
 930                    language_registry.clone(),
 931                ))
 932                .run(input, ToolCallEventStream::test().0, cx)
 933            });
 934
 935            // Stream the content with trailing whitespace
 936            cx.executor().run_until_parked();
 937            model.send_last_completion_stream_text_chunk(
 938                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
 939            );
 940            model.end_last_completion_stream();
 941
 942            edit_task.await
 943        };
 944        assert!(edit_result.is_ok());
 945
 946        // Wait for any async operations (e.g. formatting) to complete
 947        cx.executor().run_until_parked();
 948
 949        // Read the file to verify trailing whitespace was removed automatically
 950        assert_eq!(
 951            // Ignore carriage returns on Windows
 952            fs.load(path!("/root/src/main.rs").as_ref())
 953                .await
 954                .unwrap()
 955                .replace("\r\n", "\n"),
 956            "fn main() {\n    println!(\"Hello!\");\n}\n",
 957            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
 958        );
 959
 960        // Next, test with remove_trailing_whitespace_on_save disabled
 961        cx.update(|cx| {
 962            SettingsStore::update_global(cx, |store, cx| {
 963                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
 964                    cx,
 965                    |settings| {
 966                        settings.defaults.remove_trailing_whitespace_on_save = Some(false);
 967                    },
 968                );
 969            });
 970        });
 971
 972        // Stream edits again with trailing whitespace
 973        let edit_result = {
 974            let edit_task = cx.update(|cx| {
 975                let input = EditFileToolInput {
 976                    display_description: "Update main function".into(),
 977                    path: "root/src/main.rs".into(),
 978                    mode: EditFileMode::Overwrite,
 979                };
 980                Arc::new(EditFileTool::new(thread.downgrade(), language_registry)).run(
 981                    input,
 982                    ToolCallEventStream::test().0,
 983                    cx,
 984                )
 985            });
 986
 987            // Stream the content with trailing whitespace
 988            cx.executor().run_until_parked();
 989            model.send_last_completion_stream_text_chunk(
 990                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
 991            );
 992            model.end_last_completion_stream();
 993
 994            edit_task.await
 995        };
 996        assert!(edit_result.is_ok());
 997
 998        // Wait for any async operations (e.g. formatting) to complete
 999        cx.executor().run_until_parked();
1000
1001        // Verify the file still has trailing whitespace
1002        // Read the file again - it should still have trailing whitespace
1003        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1004        assert_eq!(
1005            // Ignore carriage returns on Windows
1006            final_content.replace("\r\n", "\n"),
1007            CONTENT_WITH_TRAILING_WHITESPACE,
1008            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1009        );
1010    }
1011
1012    #[gpui::test]
1013    async fn test_authorize(cx: &mut TestAppContext) {
1014        init_test(cx);
1015        let fs = project::FakeFs::new(cx.executor());
1016        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1017        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1018        let context_server_registry =
1019            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1020        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1021        let model = Arc::new(FakeLanguageModel::default());
1022        let thread = cx.new(|cx| {
1023            Thread::new(
1024                generate_session_id(),
1025                project,
1026                Rc::default(),
1027                context_server_registry,
1028                action_log.clone(),
1029                Templates::new(),
1030                model.clone(),
1031                cx,
1032            )
1033        });
1034        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1035        fs.insert_tree("/root", json!({})).await;
1036
1037        // Test 1: Path with .zed component should require confirmation
1038        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1039        let _auth = cx.update(|cx| {
1040            tool.authorize(
1041                &EditFileToolInput {
1042                    display_description: "test 1".into(),
1043                    path: ".zed/settings.json".into(),
1044                    mode: EditFileMode::Edit,
1045                },
1046                &stream_tx,
1047                cx,
1048            )
1049        });
1050
1051        let event = stream_rx.expect_authorization().await;
1052        assert_eq!(
1053            event.tool_call.fields.title,
1054            Some("test 1 (local settings)".into())
1055        );
1056
1057        // Test 2: Path outside project should require confirmation
1058        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1059        let _auth = cx.update(|cx| {
1060            tool.authorize(
1061                &EditFileToolInput {
1062                    display_description: "test 2".into(),
1063                    path: "/etc/hosts".into(),
1064                    mode: EditFileMode::Edit,
1065                },
1066                &stream_tx,
1067                cx,
1068            )
1069        });
1070
1071        let event = stream_rx.expect_authorization().await;
1072        assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1073
1074        // Test 3: Relative path without .zed should not require confirmation
1075        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1076        cx.update(|cx| {
1077            tool.authorize(
1078                &EditFileToolInput {
1079                    display_description: "test 3".into(),
1080                    path: "root/src/main.rs".into(),
1081                    mode: EditFileMode::Edit,
1082                },
1083                &stream_tx,
1084                cx,
1085            )
1086        })
1087        .await
1088        .unwrap();
1089        assert!(stream_rx.try_next().is_err());
1090
1091        // Test 4: Path with .zed in the middle should require confirmation
1092        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1093        let _auth = cx.update(|cx| {
1094            tool.authorize(
1095                &EditFileToolInput {
1096                    display_description: "test 4".into(),
1097                    path: "root/.zed/tasks.json".into(),
1098                    mode: EditFileMode::Edit,
1099                },
1100                &stream_tx,
1101                cx,
1102            )
1103        });
1104        let event = stream_rx.expect_authorization().await;
1105        assert_eq!(
1106            event.tool_call.fields.title,
1107            Some("test 4 (local settings)".into())
1108        );
1109
1110        // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1111        cx.update(|cx| {
1112            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1113            settings.always_allow_tool_actions = true;
1114            agent_settings::AgentSettings::override_global(settings, cx);
1115        });
1116
1117        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1118        cx.update(|cx| {
1119            tool.authorize(
1120                &EditFileToolInput {
1121                    display_description: "test 5.1".into(),
1122                    path: ".zed/settings.json".into(),
1123                    mode: EditFileMode::Edit,
1124                },
1125                &stream_tx,
1126                cx,
1127            )
1128        })
1129        .await
1130        .unwrap();
1131        assert!(stream_rx.try_next().is_err());
1132
1133        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1134        cx.update(|cx| {
1135            tool.authorize(
1136                &EditFileToolInput {
1137                    display_description: "test 5.2".into(),
1138                    path: "/etc/hosts".into(),
1139                    mode: EditFileMode::Edit,
1140                },
1141                &stream_tx,
1142                cx,
1143            )
1144        })
1145        .await
1146        .unwrap();
1147        assert!(stream_rx.try_next().is_err());
1148    }
1149
1150    #[gpui::test]
1151    async fn test_authorize_global_config(cx: &mut TestAppContext) {
1152        init_test(cx);
1153        let fs = project::FakeFs::new(cx.executor());
1154        fs.insert_tree("/project", json!({})).await;
1155        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1156        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1157        let context_server_registry =
1158            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1159        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1160        let model = Arc::new(FakeLanguageModel::default());
1161        let thread = cx.new(|cx| {
1162            Thread::new(
1163                generate_session_id(),
1164                project,
1165                Rc::default(),
1166                context_server_registry,
1167                action_log.clone(),
1168                Templates::new(),
1169                model.clone(),
1170                cx,
1171            )
1172        });
1173        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1174
1175        // Test global config paths - these should require confirmation if they exist and are outside the project
1176        let test_cases = vec![
1177            (
1178                "/etc/hosts",
1179                true,
1180                "System file should require confirmation",
1181            ),
1182            (
1183                "/usr/local/bin/script",
1184                true,
1185                "System bin file should require confirmation",
1186            ),
1187            (
1188                "project/normal_file.rs",
1189                false,
1190                "Normal project file should not require confirmation",
1191            ),
1192        ];
1193
1194        for (path, should_confirm, description) in test_cases {
1195            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1196            let auth = cx.update(|cx| {
1197                tool.authorize(
1198                    &EditFileToolInput {
1199                        display_description: "Edit file".into(),
1200                        path: path.into(),
1201                        mode: EditFileMode::Edit,
1202                    },
1203                    &stream_tx,
1204                    cx,
1205                )
1206            });
1207
1208            if should_confirm {
1209                stream_rx.expect_authorization().await;
1210            } else {
1211                auth.await.unwrap();
1212                assert!(
1213                    stream_rx.try_next().is_err(),
1214                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1215                    description,
1216                    path
1217                );
1218            }
1219        }
1220    }
1221
1222    #[gpui::test]
1223    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1224        init_test(cx);
1225        let fs = project::FakeFs::new(cx.executor());
1226
1227        // Create multiple worktree directories
1228        fs.insert_tree(
1229            "/workspace/frontend",
1230            json!({
1231                "src": {
1232                    "main.js": "console.log('frontend');"
1233                }
1234            }),
1235        )
1236        .await;
1237        fs.insert_tree(
1238            "/workspace/backend",
1239            json!({
1240                "src": {
1241                    "main.rs": "fn main() {}"
1242                }
1243            }),
1244        )
1245        .await;
1246        fs.insert_tree(
1247            "/workspace/shared",
1248            json!({
1249                ".zed": {
1250                    "settings.json": "{}"
1251                }
1252            }),
1253        )
1254        .await;
1255
1256        // Create project with multiple worktrees
1257        let project = Project::test(
1258            fs.clone(),
1259            [
1260                path!("/workspace/frontend").as_ref(),
1261                path!("/workspace/backend").as_ref(),
1262                path!("/workspace/shared").as_ref(),
1263            ],
1264            cx,
1265        )
1266        .await;
1267        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1268        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1269        let context_server_registry =
1270            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1271        let model = Arc::new(FakeLanguageModel::default());
1272        let thread = cx.new(|cx| {
1273            Thread::new(
1274                generate_session_id(),
1275                project.clone(),
1276                Rc::default(),
1277                context_server_registry.clone(),
1278                action_log.clone(),
1279                Templates::new(),
1280                model.clone(),
1281                cx,
1282            )
1283        });
1284        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1285
1286        // Test files in different worktrees
1287        let test_cases = vec![
1288            ("frontend/src/main.js", false, "File in first worktree"),
1289            ("backend/src/main.rs", false, "File in second worktree"),
1290            (
1291                "shared/.zed/settings.json",
1292                true,
1293                ".zed file in third worktree",
1294            ),
1295            ("/etc/hosts", true, "Absolute path outside all worktrees"),
1296            (
1297                "../outside/file.txt",
1298                true,
1299                "Relative path outside worktrees",
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_edge_cases(cx: &mut TestAppContext) {
1333        init_test(cx);
1334        let fs = project::FakeFs::new(cx.executor());
1335        fs.insert_tree(
1336            "/project",
1337            json!({
1338                ".zed": {
1339                    "settings.json": "{}"
1340                },
1341                "src": {
1342                    ".zed": {
1343                        "local.json": "{}"
1344                    }
1345                }
1346            }),
1347        )
1348        .await;
1349        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1350        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1351        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1352        let context_server_registry =
1353            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1354        let model = Arc::new(FakeLanguageModel::default());
1355        let thread = cx.new(|cx| {
1356            Thread::new(
1357                generate_session_id(),
1358                project.clone(),
1359                Rc::default(),
1360                context_server_registry.clone(),
1361                action_log.clone(),
1362                Templates::new(),
1363                model.clone(),
1364                cx,
1365            )
1366        });
1367        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1368
1369        // Test edge cases
1370        let test_cases = vec![
1371            // Empty path - find_project_path returns Some for empty paths
1372            ("", false, "Empty path is treated as project root"),
1373            // Root directory
1374            ("/", true, "Root directory should be outside project"),
1375            // Parent directory references - find_project_path resolves these
1376            (
1377                "project/../other",
1378                false,
1379                "Path with .. is resolved by find_project_path",
1380            ),
1381            (
1382                "project/./src/file.rs",
1383                false,
1384                "Path with . should work normally",
1385            ),
1386            // Windows-style paths (if on Windows)
1387            #[cfg(target_os = "windows")]
1388            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1389            #[cfg(target_os = "windows")]
1390            ("project\\src\\main.rs", false, "Windows-style project path"),
1391        ];
1392
1393        for (path, should_confirm, description) in test_cases {
1394            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1395            let auth = cx.update(|cx| {
1396                tool.authorize(
1397                    &EditFileToolInput {
1398                        display_description: "Edit file".into(),
1399                        path: path.into(),
1400                        mode: EditFileMode::Edit,
1401                    },
1402                    &stream_tx,
1403                    cx,
1404                )
1405            });
1406
1407            if should_confirm {
1408                stream_rx.expect_authorization().await;
1409            } else {
1410                auth.await.unwrap();
1411                assert!(
1412                    stream_rx.try_next().is_err(),
1413                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1414                    description,
1415                    path
1416                );
1417            }
1418        }
1419    }
1420
1421    #[gpui::test]
1422    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1423        init_test(cx);
1424        let fs = project::FakeFs::new(cx.executor());
1425        fs.insert_tree(
1426            "/project",
1427            json!({
1428                "existing.txt": "content",
1429                ".zed": {
1430                    "settings.json": "{}"
1431                }
1432            }),
1433        )
1434        .await;
1435        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1436        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1437        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1438        let context_server_registry =
1439            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1440        let model = Arc::new(FakeLanguageModel::default());
1441        let thread = cx.new(|cx| {
1442            Thread::new(
1443                generate_session_id(),
1444                project.clone(),
1445                Rc::default(),
1446                context_server_registry.clone(),
1447                action_log.clone(),
1448                Templates::new(),
1449                model.clone(),
1450                cx,
1451            )
1452        });
1453        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1454
1455        // Test different EditFileMode values
1456        let modes = vec![
1457            EditFileMode::Edit,
1458            EditFileMode::Create,
1459            EditFileMode::Overwrite,
1460        ];
1461
1462        for mode in modes {
1463            // Test .zed path with different modes
1464            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1465            let _auth = cx.update(|cx| {
1466                tool.authorize(
1467                    &EditFileToolInput {
1468                        display_description: "Edit settings".into(),
1469                        path: "project/.zed/settings.json".into(),
1470                        mode: mode.clone(),
1471                    },
1472                    &stream_tx,
1473                    cx,
1474                )
1475            });
1476
1477            stream_rx.expect_authorization().await;
1478
1479            // Test outside path with different modes
1480            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1481            let _auth = cx.update(|cx| {
1482                tool.authorize(
1483                    &EditFileToolInput {
1484                        display_description: "Edit file".into(),
1485                        path: "/outside/file.txt".into(),
1486                        mode: mode.clone(),
1487                    },
1488                    &stream_tx,
1489                    cx,
1490                )
1491            });
1492
1493            stream_rx.expect_authorization().await;
1494
1495            // Test normal path with different modes
1496            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1497            cx.update(|cx| {
1498                tool.authorize(
1499                    &EditFileToolInput {
1500                        display_description: "Edit file".into(),
1501                        path: "project/normal.txt".into(),
1502                        mode: mode.clone(),
1503                    },
1504                    &stream_tx,
1505                    cx,
1506                )
1507            })
1508            .await
1509            .unwrap();
1510            assert!(stream_rx.try_next().is_err());
1511        }
1512    }
1513
1514    #[gpui::test]
1515    async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1516        init_test(cx);
1517        let fs = project::FakeFs::new(cx.executor());
1518        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1519        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1520        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1521        let context_server_registry =
1522            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1523        let model = Arc::new(FakeLanguageModel::default());
1524        let thread = cx.new(|cx| {
1525            Thread::new(
1526                generate_session_id(),
1527                project.clone(),
1528                Rc::default(),
1529                context_server_registry,
1530                action_log.clone(),
1531                Templates::new(),
1532                model.clone(),
1533                cx,
1534            )
1535        });
1536        let tool = Arc::new(EditFileTool::new(thread.downgrade(), language_registry));
1537
1538        assert_eq!(
1539            tool.initial_title(Err(json!({
1540                "path": "src/main.rs",
1541                "display_description": "",
1542                "old_string": "old code",
1543                "new_string": "new code"
1544            }))),
1545            "src/main.rs"
1546        );
1547        assert_eq!(
1548            tool.initial_title(Err(json!({
1549                "path": "",
1550                "display_description": "Fix error handling",
1551                "old_string": "old code",
1552                "new_string": "new code"
1553            }))),
1554            "Fix error handling"
1555        );
1556        assert_eq!(
1557            tool.initial_title(Err(json!({
1558                "path": "src/main.rs",
1559                "display_description": "Fix error handling",
1560                "old_string": "old code",
1561                "new_string": "new code"
1562            }))),
1563            "Fix error handling"
1564        );
1565        assert_eq!(
1566            tool.initial_title(Err(json!({
1567                "path": "",
1568                "display_description": "",
1569                "old_string": "old code",
1570                "new_string": "new code"
1571            }))),
1572            DEFAULT_UI_TEXT
1573        );
1574        assert_eq!(
1575            tool.initial_title(Err(serde_json::Value::Null)),
1576            DEFAULT_UI_TEXT
1577        );
1578    }
1579
1580    fn init_test(cx: &mut TestAppContext) {
1581        cx.update(|cx| {
1582            let settings_store = SettingsStore::test(cx);
1583            cx.set_global(settings_store);
1584            language::init(cx);
1585            TelemetrySettings::register(cx);
1586            agent_settings::AgentSettings::register(cx);
1587            Project::init_settings(cx);
1588        });
1589    }
1590}