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