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