edit_file_tool.rs

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