edit_file_tool.rs

   1use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
   2use super::save_file_tool::SaveFileTool;
   3use super::tool_permissions::authorize_file_edit;
   4use crate::{
   5    AgentTool, Templates, Thread, ToolCallEventStream, ToolInput,
   6    edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat},
   7};
   8use acp_thread::Diff;
   9use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
  10use anyhow::{Context as _, Result};
  11use cloud_llm_client::CompletionIntent;
  12use collections::HashSet;
  13use futures::{FutureExt as _, StreamExt as _};
  14use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
  15use indoc::formatdoc;
  16use language::language_settings::{self, FormatOnSave};
  17use language::{LanguageRegistry, ToPoint};
  18use language_model::LanguageModelToolResultContent;
  19use project::lsp_store::{FormatTrigger, LspFormatTarget};
  20use project::{Project, ProjectPath};
  21use schemars::JsonSchema;
  22use serde::{Deserialize, Serialize};
  23use std::path::PathBuf;
  24use std::sync::Arc;
  25use ui::SharedString;
  26use util::ResultExt;
  27use util::rel_path::RelPath;
  28
  29const DEFAULT_UI_TEXT: &str = "Editing file";
  30
  31/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.
  32///
  33/// Before using this tool:
  34///
  35/// 1. Use the `read_file` tool to understand the file's contents and context
  36///
  37/// 2. Verify the directory path is correct (only applicable when creating new files):
  38///    - Use the `list_directory` tool to verify the parent directory exists and is the correct location
  39#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  40pub struct EditFileToolInput {
  41    /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.
  42    ///
  43    /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
  44    ///
  45    /// NEVER mention the file path in this description.
  46    ///
  47    /// <example>Fix API endpoint URLs</example>
  48    /// <example>Update copyright year in `page_footer`</example>
  49    ///
  50    /// Make sure to include this field before all the others in the input object so that we can display it immediately.
  51    pub display_description: String,
  52
  53    /// The full path of the file to create or modify in the project.
  54    ///
  55    /// WARNING: When specifying which file path need changing, you MUST 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 would be ambiguous and the call would fail!
  65    /// </example>
  66    ///
  67    /// <example>
  68    /// `frontend/db.js`
  69    /// </example>
  70    pub path: PathBuf,
  71    /// The mode of operation on the file. Possible values:
  72    /// - 'edit': Make granular edits to an existing file.
  73    /// - 'create': Create a new file if it doesn't exist.
  74    /// - 'overwrite': Replace the entire contents of an existing file.
  75    ///
  76    /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
  77    pub mode: EditFileMode,
  78}
  79
  80#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  81struct EditFileToolPartialInput {
  82    #[serde(default)]
  83    path: String,
  84    #[serde(default)]
  85    display_description: String,
  86}
  87
  88#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  89#[serde(rename_all = "lowercase")]
  90#[schemars(inline)]
  91pub enum EditFileMode {
  92    Edit,
  93    Create,
  94    Overwrite,
  95}
  96
  97#[derive(Debug, Serialize, Deserialize)]
  98#[serde(untagged)]
  99pub enum EditFileToolOutput {
 100    Success {
 101        #[serde(alias = "original_path")]
 102        input_path: PathBuf,
 103        new_text: String,
 104        old_text: Arc<String>,
 105        #[serde(default)]
 106        diff: String,
 107    },
 108    Error {
 109        error: String,
 110    },
 111}
 112
 113impl std::fmt::Display for EditFileToolOutput {
 114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 115        match self {
 116            EditFileToolOutput::Success {
 117                diff, input_path, ..
 118            } => {
 119                if diff.is_empty() {
 120                    write!(f, "No edits were made.")
 121                } else {
 122                    write!(
 123                        f,
 124                        "Edited {}:\n\n```diff\n{diff}\n```",
 125                        input_path.display()
 126                    )
 127                }
 128            }
 129            EditFileToolOutput::Error { error } => write!(f, "{error}"),
 130        }
 131    }
 132}
 133
 134impl From<EditFileToolOutput> for LanguageModelToolResultContent {
 135    fn from(output: EditFileToolOutput) -> Self {
 136        output.to_string().into()
 137    }
 138}
 139
 140pub struct EditFileTool {
 141    thread: WeakEntity<Thread>,
 142    language_registry: Arc<LanguageRegistry>,
 143    project: Entity<Project>,
 144    templates: Arc<Templates>,
 145}
 146
 147impl EditFileTool {
 148    pub fn new(
 149        project: Entity<Project>,
 150        thread: WeakEntity<Thread>,
 151        language_registry: Arc<LanguageRegistry>,
 152        templates: Arc<Templates>,
 153    ) -> Self {
 154        Self {
 155            project,
 156            thread,
 157            language_registry,
 158            templates,
 159        }
 160    }
 161
 162    fn authorize(
 163        &self,
 164        input: &EditFileToolInput,
 165        event_stream: &ToolCallEventStream,
 166        cx: &mut App,
 167    ) -> Task<Result<()>> {
 168        authorize_file_edit(
 169            Self::NAME,
 170            &input.path,
 171            &input.display_description,
 172            &self.thread,
 173            event_stream,
 174            cx,
 175        )
 176    }
 177}
 178
 179impl AgentTool for EditFileTool {
 180    type Input = EditFileToolInput;
 181    type Output = EditFileToolOutput;
 182
 183    const NAME: &'static str = "edit_file";
 184
 185    fn kind() -> acp::ToolKind {
 186        acp::ToolKind::Edit
 187    }
 188
 189    fn initial_title(
 190        &self,
 191        input: Result<Self::Input, serde_json::Value>,
 192        cx: &mut App,
 193    ) -> SharedString {
 194        match input {
 195            Ok(input) => self
 196                .project
 197                .read(cx)
 198                .find_project_path(&input.path, cx)
 199                .and_then(|project_path| {
 200                    self.project
 201                        .read(cx)
 202                        .short_full_path_for_project_path(&project_path, cx)
 203                })
 204                .unwrap_or(input.path.to_string_lossy().into_owned())
 205                .into(),
 206            Err(raw_input) => {
 207                if let Some(input) =
 208                    serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
 209                {
 210                    let path = input.path.trim();
 211                    if !path.is_empty() {
 212                        return self
 213                            .project
 214                            .read(cx)
 215                            .find_project_path(&input.path, cx)
 216                            .and_then(|project_path| {
 217                                self.project
 218                                    .read(cx)
 219                                    .short_full_path_for_project_path(&project_path, cx)
 220                            })
 221                            .unwrap_or(input.path)
 222                            .into();
 223                    }
 224
 225                    let description = input.display_description.trim();
 226                    if !description.is_empty() {
 227                        return description.to_string().into();
 228                    }
 229                }
 230
 231                DEFAULT_UI_TEXT.into()
 232            }
 233        }
 234    }
 235
 236    fn run(
 237        self: Arc<Self>,
 238        input: ToolInput<Self::Input>,
 239        event_stream: ToolCallEventStream,
 240        cx: &mut App,
 241    ) -> Task<Result<Self::Output, Self::Output>> {
 242        cx.spawn(async move |cx: &mut AsyncApp| {
 243            let input = input.recv().await.map_err(|e| EditFileToolOutput::Error {
 244                error: format!("Failed to receive tool input: {e}"),
 245            })?;
 246
 247            let project = self
 248                .thread
 249                .read_with(cx, |thread, _cx| thread.project().clone())
 250                .map_err(|_| EditFileToolOutput::Error {
 251                    error: "thread was dropped".to_string(),
 252                })?;
 253
 254            let (project_path, abs_path, allow_thinking, update_agent_location, authorize) =
 255                cx.update(|cx| {
 256                    let project_path = resolve_path(&input, project.clone(), cx).map_err(|err| {
 257                        EditFileToolOutput::Error {
 258                            error: err.to_string(),
 259                        }
 260                    })?;
 261                    let abs_path = project.read(cx).absolute_path(&project_path, cx);
 262                    if let Some(abs_path) = abs_path.clone() {
 263                        event_stream.update_fields(
 264                            ToolCallUpdateFields::new()
 265                                .locations(vec![acp::ToolCallLocation::new(abs_path)]),
 266                        );
 267                    }
 268                    let allow_thinking = self
 269                        .thread
 270                        .read_with(cx, |thread, _cx| thread.thinking_enabled())
 271                        .unwrap_or(true);
 272
 273                    let update_agent_location = self.thread.read_with(cx, |thread, _cx| !thread.is_subagent()).unwrap_or_default();
 274
 275                    let authorize = self.authorize(&input, &event_stream, cx);
 276                    Ok::<_, EditFileToolOutput>((project_path, abs_path, allow_thinking, update_agent_location, authorize))
 277                })?;
 278
 279            let result: anyhow::Result<EditFileToolOutput> = async {
 280                authorize.await?;
 281
 282                let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
 283                    let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
 284                    (request, thread.model().cloned(), thread.action_log().clone())
 285                })?;
 286                let request = request?;
 287                let model = model.context("No language model configured")?;
 288
 289                let edit_format = EditFormat::from_model(model.clone())?;
 290                let edit_agent = EditAgent::new(
 291                    model,
 292                    project.clone(),
 293                    action_log.clone(),
 294                    self.templates.clone(),
 295                    edit_format,
 296                    allow_thinking,
 297                    update_agent_location,
 298                );
 299
 300                let buffer = project
 301                    .update(cx, |project, cx| {
 302                        project.open_buffer(project_path.clone(), cx)
 303                    })
 304                    .await?;
 305
 306                // Check if the file has been modified since the agent last read it
 307                if let Some(abs_path) = abs_path.as_ref() {
 308                    let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
 309                        let last_read = thread.file_read_times.get(abs_path).copied();
 310                        let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
 311                        let dirty = buffer.read(cx).is_dirty();
 312                        let has_save = thread.has_tool(SaveFileTool::NAME);
 313                        let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
 314                        (last_read, current, dirty, has_save, has_restore)
 315                    })?;
 316
 317                    // Check for unsaved changes first - these indicate modifications we don't know about
 318                    if is_dirty {
 319                        let message = match (has_save_tool, has_restore_tool) {
 320                            (true, true) => {
 321                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 322                                If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 323                                If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
 324                            }
 325                            (true, false) => {
 326                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 327                                If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
 328                                If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
 329                            }
 330                            (false, true) => {
 331                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
 332                                If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
 333                                If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
 334                            }
 335                            (false, false) => {
 336                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
 337                                then ask them to save or revert the file manually and inform you when it's ok to proceed."
 338                            }
 339                        };
 340                        anyhow::bail!("{}", message);
 341                    }
 342
 343                    // Check if the file was modified on disk since we last read it
 344                    if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
 345                        // MTime can be unreliable for comparisons, so our newtype intentionally
 346                        // doesn't support comparing them. If the mtime at all different
 347                        // (which could be because of a modification or because e.g. system clock changed),
 348                        // we pessimistically assume it was modified.
 349                        if current != last_read {
 350                            anyhow::bail!(
 351                                "The file {} has been modified since you last read it. \
 352                                Please read the file again to get the current state before editing it.",
 353                                input.path.display()
 354                            );
 355                        }
 356                    }
 357                }
 358
 359                let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
 360                event_stream.update_diff(diff.clone());
 361                let _finalize_diff = util::defer({
 362                    let diff = diff.downgrade();
 363                    let mut cx = cx.clone();
 364                    move || {
 365                        diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
 366                    }
 367                });
 368
 369                let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 370                let old_text = cx
 371                    .background_spawn({
 372                        let old_snapshot = old_snapshot.clone();
 373                        async move { Arc::new(old_snapshot.text()) }
 374                    })
 375                    .await;
 376
 377                let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 378                    edit_agent.edit(
 379                        buffer.clone(),
 380                        input.display_description.clone(),
 381                        &request,
 382                        cx,
 383                    )
 384                } else {
 385                    edit_agent.overwrite(
 386                        buffer.clone(),
 387                        input.display_description.clone(),
 388                        &request,
 389                        cx,
 390                    )
 391                };
 392
 393                let mut hallucinated_old_text = false;
 394                let mut ambiguous_ranges = Vec::new();
 395                let mut emitted_location = false;
 396                loop {
 397                    let event = futures::select! {
 398                        event = events.next().fuse() => match event {
 399                            Some(event) => event,
 400                            None => break,
 401                        },
 402                        _ = event_stream.cancelled_by_user().fuse() => {
 403                            anyhow::bail!("Edit cancelled by user");
 404                        }
 405                    };
 406                    match event {
 407                        EditAgentOutputEvent::Edited(range) => {
 408                            if !emitted_location {
 409                                let line = Some(buffer.update(cx, |buffer, _cx| {
 410                                    range.start.to_point(&buffer.snapshot()).row
 411                                }));
 412                                if let Some(abs_path) = abs_path.clone() {
 413                                    event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
 414                                }
 415                                emitted_location = true;
 416                            }
 417                        },
 418                        EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
 419                        EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
 420                        EditAgentOutputEvent::ResolvingEditRange(range) => {
 421                            diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
 422                            // if !emitted_location {
 423                            //     let line = buffer.update(cx, |buffer, _cx| {
 424                            //         range.start.to_point(&buffer.snapshot()).row
 425                            //     }).ok();
 426                            //     if let Some(abs_path) = abs_path.clone() {
 427                            //         event_stream.update_fields(ToolCallUpdateFields {
 428                            //             locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
 429                            //             ..Default::default()
 430                            //         });
 431                            //     }
 432                            // }
 433                        }
 434                    }
 435                }
 436
 437                output.await?;
 438
 439                let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
 440                    let settings = language_settings::language_settings(
 441                        buffer.language().map(|l| l.name()),
 442                        buffer.file(),
 443                        cx,
 444                    );
 445                    settings.format_on_save != FormatOnSave::Off
 446                });
 447
 448                if format_on_save_enabled {
 449                    action_log.update(cx, |log, cx| {
 450                        log.buffer_edited(buffer.clone(), cx);
 451                    });
 452
 453                    let format_task = project.update(cx, |project, cx| {
 454                        project.format(
 455                            HashSet::from_iter([buffer.clone()]),
 456                            LspFormatTarget::Buffers,
 457                            false, // Don't push to history since the tool did it.
 458                            FormatTrigger::Save,
 459                            cx,
 460                        )
 461                    });
 462                    format_task.await.log_err();
 463                }
 464
 465                project
 466                    .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
 467                    .await?;
 468
 469                action_log.update(cx, |log, cx| {
 470                    log.buffer_edited(buffer.clone(), cx);
 471                });
 472
 473                // Update the recorded read time after a successful edit so consecutive edits work
 474                if let Some(abs_path) = abs_path.as_ref() {
 475                    if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
 476                        buffer.file().and_then(|file| file.disk_state().mtime())
 477                    }) {
 478                        self.thread.update(cx, |thread, _| {
 479                            thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
 480                        })?;
 481                    }
 482                }
 483
 484                let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 485                let (new_text, unified_diff) = cx
 486                    .background_spawn({
 487                        let new_snapshot = new_snapshot.clone();
 488                        let old_text = old_text.clone();
 489                        async move {
 490                            let new_text = new_snapshot.text();
 491                            let diff = language::unified_diff(&old_text, &new_text);
 492                            (new_text, diff)
 493                        }
 494                    })
 495                    .await;
 496
 497                let input_path = input.path.display();
 498                if unified_diff.is_empty() {
 499                    anyhow::ensure!(
 500                        !hallucinated_old_text,
 501                        formatdoc! {"
 502                            Some edits were produced but none of them could be applied.
 503                            Read the relevant sections of {input_path} again so that
 504                            I can perform the requested edits.
 505                        "}
 506                    );
 507                    anyhow::ensure!(
 508                        ambiguous_ranges.is_empty(),
 509                        {
 510                            let line_numbers = ambiguous_ranges
 511                                .iter()
 512                                .map(|range| range.start.to_string())
 513                                .collect::<Vec<_>>()
 514                                .join(", ");
 515                            formatdoc! {"
 516                                <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
 517                                relevant sections of {input_path} again and extend <old_text> so
 518                                that I can perform the requested edits.
 519                            "}
 520                        }
 521                    );
 522                }
 523
 524                anyhow::Ok(EditFileToolOutput::Success {
 525                    input_path: input.path,
 526                    new_text,
 527                    old_text,
 528                    diff: unified_diff,
 529                })
 530            }.await;
 531            result
 532                .map_err(|e| EditFileToolOutput::Error { error: e.to_string() })
 533        })
 534    }
 535
 536    fn replay(
 537        &self,
 538        _input: Self::Input,
 539        output: Self::Output,
 540        event_stream: ToolCallEventStream,
 541        cx: &mut App,
 542    ) -> Result<()> {
 543        match output {
 544            EditFileToolOutput::Success {
 545                input_path,
 546                old_text,
 547                new_text,
 548                ..
 549            } => {
 550                event_stream.update_diff(cx.new(|cx| {
 551                    Diff::finalized(
 552                        input_path.to_string_lossy().into_owned(),
 553                        Some(old_text.to_string()),
 554                        new_text,
 555                        self.language_registry.clone(),
 556                        cx,
 557                    )
 558                }));
 559                Ok(())
 560            }
 561            EditFileToolOutput::Error { .. } => Ok(()),
 562        }
 563    }
 564}
 565
 566/// Validate that the file path is valid, meaning:
 567///
 568/// - For `edit` and `overwrite`, the path must point to an existing file.
 569/// - For `create`, the file must not already exist, but it's parent dir must exist.
 570fn resolve_path(
 571    input: &EditFileToolInput,
 572    project: Entity<Project>,
 573    cx: &mut App,
 574) -> Result<ProjectPath> {
 575    let project = project.read(cx);
 576
 577    match input.mode {
 578        EditFileMode::Edit | EditFileMode::Overwrite => {
 579            let path = project
 580                .find_project_path(&input.path, cx)
 581                .context("Can't edit file: path not found")?;
 582
 583            let entry = project
 584                .entry_for_path(&path, cx)
 585                .context("Can't edit file: path not found")?;
 586
 587            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 588            Ok(path)
 589        }
 590
 591        EditFileMode::Create => {
 592            if let Some(path) = project.find_project_path(&input.path, cx) {
 593                anyhow::ensure!(
 594                    project.entry_for_path(&path, cx).is_none(),
 595                    "Can't create file: file already exists"
 596                );
 597            }
 598
 599            let parent_path = input
 600                .path
 601                .parent()
 602                .context("Can't create file: incorrect path")?;
 603
 604            let parent_project_path = project.find_project_path(&parent_path, cx);
 605
 606            let parent_entry = parent_project_path
 607                .as_ref()
 608                .and_then(|path| project.entry_for_path(path, cx))
 609                .context("Can't create file: parent directory doesn't exist")?;
 610
 611            anyhow::ensure!(
 612                parent_entry.is_dir(),
 613                "Can't create file: parent is not a directory"
 614            );
 615
 616            let file_name = input
 617                .path
 618                .file_name()
 619                .and_then(|file_name| file_name.to_str())
 620                .and_then(|file_name| RelPath::unix(file_name).ok())
 621                .context("Can't create file: invalid filename")?;
 622
 623            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 624                path: parent.path.join(file_name),
 625                ..parent
 626            });
 627
 628            new_file_path.context("Can't create file")
 629        }
 630    }
 631}
 632
 633#[cfg(test)]
 634mod tests {
 635    use super::*;
 636    use crate::tools::tool_permissions::{SensitiveSettingsKind, sensitive_settings_kind};
 637    use crate::{ContextServerRegistry, Templates};
 638    use fs::Fs as _;
 639    use gpui::{TestAppContext, UpdateGlobal};
 640    use language_model::fake_provider::FakeLanguageModel;
 641    use prompt_store::ProjectContext;
 642    use serde_json::json;
 643    use settings::Settings;
 644    use settings::SettingsStore;
 645    use util::{path, rel_path::rel_path};
 646
 647    #[gpui::test]
 648    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 649        init_test(cx);
 650
 651        let fs = project::FakeFs::new(cx.executor());
 652        fs.insert_tree("/root", json!({})).await;
 653        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 654        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
 655        let context_server_registry =
 656            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 657        let model = Arc::new(FakeLanguageModel::default());
 658        let thread = cx.new(|cx| {
 659            Thread::new(
 660                project.clone(),
 661                cx.new(|_cx| ProjectContext::default()),
 662                context_server_registry,
 663                Templates::new(),
 664                Some(model),
 665                cx,
 666            )
 667        });
 668        let result = cx
 669            .update(|cx| {
 670                let input = EditFileToolInput {
 671                    display_description: "Some edit".into(),
 672                    path: "root/nonexistent_file.txt".into(),
 673                    mode: EditFileMode::Edit,
 674                };
 675                Arc::new(EditFileTool::new(
 676                    project,
 677                    thread.downgrade(),
 678                    language_registry,
 679                    Templates::new(),
 680                ))
 681                .run(
 682                    ToolInput::resolved(input),
 683                    ToolCallEventStream::test().0,
 684                    cx,
 685                )
 686            })
 687            .await;
 688        assert_eq!(
 689            result.unwrap_err().to_string(),
 690            "Can't edit file: path not found"
 691        );
 692    }
 693
 694    #[gpui::test]
 695    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
 696        let mode = &EditFileMode::Create;
 697
 698        let result = test_resolve_path(mode, "root/new.txt", cx);
 699        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 700
 701        let result = test_resolve_path(mode, "new.txt", cx);
 702        assert_resolved_path_eq(result.await, rel_path("new.txt"));
 703
 704        let result = test_resolve_path(mode, "dir/new.txt", cx);
 705        assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
 706
 707        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
 708        assert_eq!(
 709            result.await.unwrap_err().to_string(),
 710            "Can't create file: file already exists"
 711        );
 712
 713        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
 714        assert_eq!(
 715            result.await.unwrap_err().to_string(),
 716            "Can't create file: parent directory doesn't exist"
 717        );
 718    }
 719
 720    #[gpui::test]
 721    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
 722        let mode = &EditFileMode::Edit;
 723
 724        let path_with_root = "root/dir/subdir/existing.txt";
 725        let path_without_root = "dir/subdir/existing.txt";
 726        let result = test_resolve_path(mode, path_with_root, cx);
 727        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 728
 729        let result = test_resolve_path(mode, path_without_root, cx);
 730        assert_resolved_path_eq(result.await, rel_path(path_without_root));
 731
 732        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
 733        assert_eq!(
 734            result.await.unwrap_err().to_string(),
 735            "Can't edit file: path not found"
 736        );
 737
 738        let result = test_resolve_path(mode, "root/dir", cx);
 739        assert_eq!(
 740            result.await.unwrap_err().to_string(),
 741            "Can't edit file: path is a directory"
 742        );
 743    }
 744
 745    async fn test_resolve_path(
 746        mode: &EditFileMode,
 747        path: &str,
 748        cx: &mut TestAppContext,
 749    ) -> anyhow::Result<ProjectPath> {
 750        init_test(cx);
 751
 752        let fs = project::FakeFs::new(cx.executor());
 753        fs.insert_tree(
 754            "/root",
 755            json!({
 756                "dir": {
 757                    "subdir": {
 758                        "existing.txt": "hello"
 759                    }
 760                }
 761            }),
 762        )
 763        .await;
 764        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 765
 766        let input = EditFileToolInput {
 767            display_description: "Some edit".into(),
 768            path: path.into(),
 769            mode: mode.clone(),
 770        };
 771
 772        cx.update(|cx| resolve_path(&input, project, cx))
 773    }
 774
 775    #[track_caller]
 776    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
 777        let actual = path.expect("Should return valid path").path;
 778        assert_eq!(actual.as_ref(), expected);
 779    }
 780
 781    #[gpui::test]
 782    async fn test_format_on_save(cx: &mut TestAppContext) {
 783        init_test(cx);
 784
 785        let fs = project::FakeFs::new(cx.executor());
 786        fs.insert_tree("/root", json!({"src": {}})).await;
 787
 788        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 789
 790        // Set up a Rust language with LSP formatting support
 791        let rust_language = Arc::new(language::Language::new(
 792            language::LanguageConfig {
 793                name: "Rust".into(),
 794                matcher: language::LanguageMatcher {
 795                    path_suffixes: vec!["rs".to_string()],
 796                    ..Default::default()
 797                },
 798                ..Default::default()
 799            },
 800            None,
 801        ));
 802
 803        // Register the language and fake LSP
 804        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 805        language_registry.add(rust_language);
 806
 807        let mut fake_language_servers = language_registry.register_fake_lsp(
 808            "Rust",
 809            language::FakeLspAdapter {
 810                capabilities: lsp::ServerCapabilities {
 811                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
 812                    ..Default::default()
 813                },
 814                ..Default::default()
 815            },
 816        );
 817
 818        // Create the file
 819        fs.save(
 820            path!("/root/src/main.rs").as_ref(),
 821            &"initial content".into(),
 822            language::LineEnding::Unix,
 823        )
 824        .await
 825        .unwrap();
 826
 827        // Open the buffer to trigger LSP initialization
 828        let buffer = project
 829            .update(cx, |project, cx| {
 830                project.open_local_buffer(path!("/root/src/main.rs"), cx)
 831            })
 832            .await
 833            .unwrap();
 834
 835        // Register the buffer with language servers
 836        let _handle = project.update(cx, |project, cx| {
 837            project.register_buffer_with_language_servers(&buffer, cx)
 838        });
 839
 840        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
 841        const FORMATTED_CONTENT: &str =
 842            "This file was formatted by the fake formatter in the test.\n";
 843
 844        // Get the fake language server and set up formatting handler
 845        let fake_language_server = fake_language_servers.next().await.unwrap();
 846        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
 847            |_, _| async move {
 848                Ok(Some(vec![lsp::TextEdit {
 849                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
 850                    new_text: FORMATTED_CONTENT.to_string(),
 851                }]))
 852            }
 853        });
 854
 855        let context_server_registry =
 856            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 857        let model = Arc::new(FakeLanguageModel::default());
 858        let thread = cx.new(|cx| {
 859            Thread::new(
 860                project.clone(),
 861                cx.new(|_cx| ProjectContext::default()),
 862                context_server_registry,
 863                Templates::new(),
 864                Some(model.clone()),
 865                cx,
 866            )
 867        });
 868
 869        // First, test with format_on_save enabled
 870        cx.update(|cx| {
 871            SettingsStore::update_global(cx, |store, cx| {
 872                store.update_user_settings(cx, |settings| {
 873                    settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
 874                    settings.project.all_languages.defaults.formatter =
 875                        Some(language::language_settings::FormatterList::default());
 876                });
 877            });
 878        });
 879
 880        // Have the model stream unformatted content
 881        let edit_result = {
 882            let edit_task = cx.update(|cx| {
 883                let input = EditFileToolInput {
 884                    display_description: "Create main function".into(),
 885                    path: "root/src/main.rs".into(),
 886                    mode: EditFileMode::Overwrite,
 887                };
 888                Arc::new(EditFileTool::new(
 889                    project.clone(),
 890                    thread.downgrade(),
 891                    language_registry.clone(),
 892                    Templates::new(),
 893                ))
 894                .run(
 895                    ToolInput::resolved(input),
 896                    ToolCallEventStream::test().0,
 897                    cx,
 898                )
 899            });
 900
 901            // Stream the unformatted content
 902            cx.executor().run_until_parked();
 903            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 904            model.end_last_completion_stream();
 905
 906            edit_task.await
 907        };
 908        assert!(edit_result.is_ok());
 909
 910        // Wait for any async operations (e.g. formatting) to complete
 911        cx.executor().run_until_parked();
 912
 913        // Read the file to verify it was formatted automatically
 914        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 915        assert_eq!(
 916            // Ignore carriage returns on Windows
 917            new_content.replace("\r\n", "\n"),
 918            FORMATTED_CONTENT,
 919            "Code should be formatted when format_on_save is enabled"
 920        );
 921
 922        let stale_buffer_count = thread
 923            .read_with(cx, |thread, _cx| thread.action_log.clone())
 924            .read_with(cx, |log, cx| log.stale_buffers(cx).count());
 925
 926        assert_eq!(
 927            stale_buffer_count, 0,
 928            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
 929             This causes the agent to think the file was modified externally when it was just formatted.",
 930            stale_buffer_count
 931        );
 932
 933        // Next, test with format_on_save disabled
 934        cx.update(|cx| {
 935            SettingsStore::update_global(cx, |store, cx| {
 936                store.update_user_settings(cx, |settings| {
 937                    settings.project.all_languages.defaults.format_on_save =
 938                        Some(FormatOnSave::Off);
 939                });
 940            });
 941        });
 942
 943        // Stream unformatted edits again
 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(
 952                    project.clone(),
 953                    thread.downgrade(),
 954                    language_registry,
 955                    Templates::new(),
 956                ))
 957                .run(
 958                    ToolInput::resolved(input),
 959                    ToolCallEventStream::test().0,
 960                    cx,
 961                )
 962            });
 963
 964            // Stream the unformatted content
 965            cx.executor().run_until_parked();
 966            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
 967            model.end_last_completion_stream();
 968
 969            edit_task.await
 970        };
 971        assert!(edit_result.is_ok());
 972
 973        // Wait for any async operations (e.g. formatting) to complete
 974        cx.executor().run_until_parked();
 975
 976        // Verify the file was not formatted
 977        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
 978        assert_eq!(
 979            // Ignore carriage returns on Windows
 980            new_content.replace("\r\n", "\n"),
 981            UNFORMATTED_CONTENT,
 982            "Code should not be formatted when format_on_save is disabled"
 983        );
 984    }
 985
 986    #[gpui::test]
 987    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
 988        init_test(cx);
 989
 990        let fs = project::FakeFs::new(cx.executor());
 991        fs.insert_tree("/root", json!({"src": {}})).await;
 992
 993        // Create a simple file with trailing whitespace
 994        fs.save(
 995            path!("/root/src/main.rs").as_ref(),
 996            &"initial content".into(),
 997            language::LineEnding::Unix,
 998        )
 999        .await
1000        .unwrap();
1001
1002        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1003        let context_server_registry =
1004            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1005        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1006        let model = Arc::new(FakeLanguageModel::default());
1007        let thread = cx.new(|cx| {
1008            Thread::new(
1009                project.clone(),
1010                cx.new(|_cx| ProjectContext::default()),
1011                context_server_registry,
1012                Templates::new(),
1013                Some(model.clone()),
1014                cx,
1015            )
1016        });
1017
1018        // First, test with remove_trailing_whitespace_on_save enabled
1019        cx.update(|cx| {
1020            SettingsStore::update_global(cx, |store, cx| {
1021                store.update_user_settings(cx, |settings| {
1022                    settings
1023                        .project
1024                        .all_languages
1025                        .defaults
1026                        .remove_trailing_whitespace_on_save = Some(true);
1027                });
1028            });
1029        });
1030
1031        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1032            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
1033
1034        // Have the model stream content that contains trailing whitespace
1035        let edit_result = {
1036            let edit_task = cx.update(|cx| {
1037                let input = EditFileToolInput {
1038                    display_description: "Create main function".into(),
1039                    path: "root/src/main.rs".into(),
1040                    mode: EditFileMode::Overwrite,
1041                };
1042                Arc::new(EditFileTool::new(
1043                    project.clone(),
1044                    thread.downgrade(),
1045                    language_registry.clone(),
1046                    Templates::new(),
1047                ))
1048                .run(
1049                    ToolInput::resolved(input),
1050                    ToolCallEventStream::test().0,
1051                    cx,
1052                )
1053            });
1054
1055            // Stream the content with trailing whitespace
1056            cx.executor().run_until_parked();
1057            model.send_last_completion_stream_text_chunk(
1058                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1059            );
1060            model.end_last_completion_stream();
1061
1062            edit_task.await
1063        };
1064        assert!(edit_result.is_ok());
1065
1066        // Wait for any async operations (e.g. formatting) to complete
1067        cx.executor().run_until_parked();
1068
1069        // Read the file to verify trailing whitespace was removed automatically
1070        assert_eq!(
1071            // Ignore carriage returns on Windows
1072            fs.load(path!("/root/src/main.rs").as_ref())
1073                .await
1074                .unwrap()
1075                .replace("\r\n", "\n"),
1076            "fn main() {\n    println!(\"Hello!\");\n}\n",
1077            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1078        );
1079
1080        // Next, test with remove_trailing_whitespace_on_save disabled
1081        cx.update(|cx| {
1082            SettingsStore::update_global(cx, |store, cx| {
1083                store.update_user_settings(cx, |settings| {
1084                    settings
1085                        .project
1086                        .all_languages
1087                        .defaults
1088                        .remove_trailing_whitespace_on_save = Some(false);
1089                });
1090            });
1091        });
1092
1093        // Stream edits again with trailing whitespace
1094        let edit_result = {
1095            let edit_task = cx.update(|cx| {
1096                let input = EditFileToolInput {
1097                    display_description: "Update main function".into(),
1098                    path: "root/src/main.rs".into(),
1099                    mode: EditFileMode::Overwrite,
1100                };
1101                Arc::new(EditFileTool::new(
1102                    project.clone(),
1103                    thread.downgrade(),
1104                    language_registry,
1105                    Templates::new(),
1106                ))
1107                .run(
1108                    ToolInput::resolved(input),
1109                    ToolCallEventStream::test().0,
1110                    cx,
1111                )
1112            });
1113
1114            // Stream the content with trailing whitespace
1115            cx.executor().run_until_parked();
1116            model.send_last_completion_stream_text_chunk(
1117                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1118            );
1119            model.end_last_completion_stream();
1120
1121            edit_task.await
1122        };
1123        assert!(edit_result.is_ok());
1124
1125        // Wait for any async operations (e.g. formatting) to complete
1126        cx.executor().run_until_parked();
1127
1128        // Verify the file still has trailing whitespace
1129        // Read the file again - it should still have trailing whitespace
1130        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1131        assert_eq!(
1132            // Ignore carriage returns on Windows
1133            final_content.replace("\r\n", "\n"),
1134            CONTENT_WITH_TRAILING_WHITESPACE,
1135            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1136        );
1137    }
1138
1139    #[gpui::test]
1140    async fn test_authorize(cx: &mut TestAppContext) {
1141        init_test(cx);
1142        let fs = project::FakeFs::new(cx.executor());
1143        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1144        let context_server_registry =
1145            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1146        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1147        let model = Arc::new(FakeLanguageModel::default());
1148        let thread = cx.new(|cx| {
1149            Thread::new(
1150                project.clone(),
1151                cx.new(|_cx| ProjectContext::default()),
1152                context_server_registry,
1153                Templates::new(),
1154                Some(model.clone()),
1155                cx,
1156            )
1157        });
1158        let tool = Arc::new(EditFileTool::new(
1159            project.clone(),
1160            thread.downgrade(),
1161            language_registry,
1162            Templates::new(),
1163        ));
1164        fs.insert_tree("/root", json!({})).await;
1165
1166        // Test 1: Path with .zed component should require confirmation
1167        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1168        let _auth = cx.update(|cx| {
1169            tool.authorize(
1170                &EditFileToolInput {
1171                    display_description: "test 1".into(),
1172                    path: ".zed/settings.json".into(),
1173                    mode: EditFileMode::Edit,
1174                },
1175                &stream_tx,
1176                cx,
1177            )
1178        });
1179
1180        let event = stream_rx.expect_authorization().await;
1181        assert_eq!(
1182            event.tool_call.fields.title,
1183            Some("test 1 (local settings)".into())
1184        );
1185
1186        // Test 2: Path outside project should require confirmation
1187        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1188        let _auth = cx.update(|cx| {
1189            tool.authorize(
1190                &EditFileToolInput {
1191                    display_description: "test 2".into(),
1192                    path: "/etc/hosts".into(),
1193                    mode: EditFileMode::Edit,
1194                },
1195                &stream_tx,
1196                cx,
1197            )
1198        });
1199
1200        let event = stream_rx.expect_authorization().await;
1201        assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1202
1203        // Test 3: Relative path without .zed should not require confirmation
1204        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1205        cx.update(|cx| {
1206            tool.authorize(
1207                &EditFileToolInput {
1208                    display_description: "test 3".into(),
1209                    path: "root/src/main.rs".into(),
1210                    mode: EditFileMode::Edit,
1211                },
1212                &stream_tx,
1213                cx,
1214            )
1215        })
1216        .await
1217        .unwrap();
1218        assert!(stream_rx.try_next().is_err());
1219
1220        // Test 4: Path with .zed in the middle should require confirmation
1221        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1222        let _auth = cx.update(|cx| {
1223            tool.authorize(
1224                &EditFileToolInput {
1225                    display_description: "test 4".into(),
1226                    path: "root/.zed/tasks.json".into(),
1227                    mode: EditFileMode::Edit,
1228                },
1229                &stream_tx,
1230                cx,
1231            )
1232        });
1233        let event = stream_rx.expect_authorization().await;
1234        assert_eq!(
1235            event.tool_call.fields.title,
1236            Some("test 4 (local settings)".into())
1237        );
1238
1239        // Test 5: When global default is allow, sensitive and outside-project
1240        // paths still require confirmation
1241        cx.update(|cx| {
1242            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1243            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1244            agent_settings::AgentSettings::override_global(settings, cx);
1245        });
1246
1247        // 5.1: .zed/settings.json is a sensitive path — still prompts
1248        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1249        let _auth = cx.update(|cx| {
1250            tool.authorize(
1251                &EditFileToolInput {
1252                    display_description: "test 5.1".into(),
1253                    path: ".zed/settings.json".into(),
1254                    mode: EditFileMode::Edit,
1255                },
1256                &stream_tx,
1257                cx,
1258            )
1259        });
1260        let event = stream_rx.expect_authorization().await;
1261        assert_eq!(
1262            event.tool_call.fields.title,
1263            Some("test 5.1 (local settings)".into())
1264        );
1265
1266        // 5.2: /etc/hosts is outside the project, but Allow auto-approves
1267        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1268        cx.update(|cx| {
1269            tool.authorize(
1270                &EditFileToolInput {
1271                    display_description: "test 5.2".into(),
1272                    path: "/etc/hosts".into(),
1273                    mode: EditFileMode::Edit,
1274                },
1275                &stream_tx,
1276                cx,
1277            )
1278        })
1279        .await
1280        .unwrap();
1281        assert!(stream_rx.try_next().is_err());
1282
1283        // 5.3: Normal in-project path with allow — no confirmation needed
1284        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1285        cx.update(|cx| {
1286            tool.authorize(
1287                &EditFileToolInput {
1288                    display_description: "test 5.3".into(),
1289                    path: "root/src/main.rs".into(),
1290                    mode: EditFileMode::Edit,
1291                },
1292                &stream_tx,
1293                cx,
1294            )
1295        })
1296        .await
1297        .unwrap();
1298        assert!(stream_rx.try_next().is_err());
1299
1300        // 5.4: With Confirm default, non-project paths still prompt
1301        cx.update(|cx| {
1302            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1303            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
1304            agent_settings::AgentSettings::override_global(settings, cx);
1305        });
1306
1307        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1308        let _auth = cx.update(|cx| {
1309            tool.authorize(
1310                &EditFileToolInput {
1311                    display_description: "test 5.4".into(),
1312                    path: "/etc/hosts".into(),
1313                    mode: EditFileMode::Edit,
1314                },
1315                &stream_tx,
1316                cx,
1317            )
1318        });
1319
1320        let event = stream_rx.expect_authorization().await;
1321        assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
1322    }
1323
1324    #[gpui::test]
1325    async fn test_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) {
1326        init_test(cx);
1327
1328        let fs = project::FakeFs::new(cx.executor());
1329        fs.insert_tree("/root", json!({})).await;
1330        fs.insert_tree("/outside", json!({})).await;
1331        fs.insert_symlink("/root/link", PathBuf::from("/outside"))
1332            .await;
1333
1334        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1335        let context_server_registry =
1336            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1337        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1338        let model = Arc::new(FakeLanguageModel::default());
1339        let thread = cx.new(|cx| {
1340            Thread::new(
1341                project.clone(),
1342                cx.new(|_cx| ProjectContext::default()),
1343                context_server_registry,
1344                Templates::new(),
1345                Some(model),
1346                cx,
1347            )
1348        });
1349        let tool = Arc::new(EditFileTool::new(
1350            project,
1351            thread.downgrade(),
1352            language_registry,
1353            Templates::new(),
1354        ));
1355
1356        cx.update(|cx| {
1357            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1358            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1359            agent_settings::AgentSettings::override_global(settings, cx);
1360        });
1361
1362        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1363        let authorize_task = cx.update(|cx| {
1364            tool.authorize(
1365                &EditFileToolInput {
1366                    display_description: "create through symlink".into(),
1367                    path: "link/new.txt".into(),
1368                    mode: EditFileMode::Create,
1369                },
1370                &stream_tx,
1371                cx,
1372            )
1373        });
1374
1375        let event = stream_rx.expect_authorization().await;
1376        assert!(
1377            event
1378                .tool_call
1379                .fields
1380                .title
1381                .as_deref()
1382                .is_some_and(|title| title.contains("points outside the project")),
1383            "Expected symlink escape authorization for create under external symlink"
1384        );
1385
1386        event
1387            .response
1388            .send(acp::PermissionOptionId::new("allow"))
1389            .unwrap();
1390        authorize_task.await.unwrap();
1391    }
1392
1393    #[gpui::test]
1394    async fn test_edit_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1395        init_test(cx);
1396
1397        let fs = project::FakeFs::new(cx.executor());
1398        fs.insert_tree(
1399            path!("/root"),
1400            json!({
1401                "src": { "main.rs": "fn main() {}" }
1402            }),
1403        )
1404        .await;
1405        fs.insert_tree(
1406            path!("/outside"),
1407            json!({
1408                "config.txt": "old content"
1409            }),
1410        )
1411        .await;
1412        fs.create_symlink(
1413            path!("/root/link_to_external").as_ref(),
1414            PathBuf::from("/outside"),
1415        )
1416        .await
1417        .unwrap();
1418
1419        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1420        cx.executor().run_until_parked();
1421
1422        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1423        let context_server_registry =
1424            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1425        let model = Arc::new(FakeLanguageModel::default());
1426        let thread = cx.new(|cx| {
1427            Thread::new(
1428                project.clone(),
1429                cx.new(|_cx| ProjectContext::default()),
1430                context_server_registry,
1431                Templates::new(),
1432                Some(model),
1433                cx,
1434            )
1435        });
1436        let tool = Arc::new(EditFileTool::new(
1437            project.clone(),
1438            thread.downgrade(),
1439            language_registry,
1440            Templates::new(),
1441        ));
1442
1443        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1444        let _authorize_task = cx.update(|cx| {
1445            tool.authorize(
1446                &EditFileToolInput {
1447                    display_description: "edit through symlink".into(),
1448                    path: PathBuf::from("link_to_external/config.txt"),
1449                    mode: EditFileMode::Edit,
1450                },
1451                &stream_tx,
1452                cx,
1453            )
1454        });
1455
1456        let auth = stream_rx.expect_authorization().await;
1457        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1458        assert!(
1459            title.contains("points outside the project"),
1460            "title should mention symlink escape, got: {title}"
1461        );
1462    }
1463
1464    #[gpui::test]
1465    async fn test_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
1466        init_test(cx);
1467
1468        let fs = project::FakeFs::new(cx.executor());
1469        fs.insert_tree(
1470            path!("/root"),
1471            json!({
1472                "src": { "main.rs": "fn main() {}" }
1473            }),
1474        )
1475        .await;
1476        fs.insert_tree(
1477            path!("/outside"),
1478            json!({
1479                "config.txt": "old content"
1480            }),
1481        )
1482        .await;
1483        fs.create_symlink(
1484            path!("/root/link_to_external").as_ref(),
1485            PathBuf::from("/outside"),
1486        )
1487        .await
1488        .unwrap();
1489
1490        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1491        cx.executor().run_until_parked();
1492
1493        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1494        let context_server_registry =
1495            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1496        let model = Arc::new(FakeLanguageModel::default());
1497        let thread = cx.new(|cx| {
1498            Thread::new(
1499                project.clone(),
1500                cx.new(|_cx| ProjectContext::default()),
1501                context_server_registry,
1502                Templates::new(),
1503                Some(model),
1504                cx,
1505            )
1506        });
1507        let tool = Arc::new(EditFileTool::new(
1508            project.clone(),
1509            thread.downgrade(),
1510            language_registry,
1511            Templates::new(),
1512        ));
1513
1514        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1515        let authorize_task = cx.update(|cx| {
1516            tool.authorize(
1517                &EditFileToolInput {
1518                    display_description: "edit through symlink".into(),
1519                    path: PathBuf::from("link_to_external/config.txt"),
1520                    mode: EditFileMode::Edit,
1521                },
1522                &stream_tx,
1523                cx,
1524            )
1525        });
1526
1527        let auth = stream_rx.expect_authorization().await;
1528        drop(auth); // deny by dropping
1529
1530        let result = authorize_task.await;
1531        assert!(result.is_err(), "should fail when denied");
1532    }
1533
1534    #[gpui::test]
1535    async fn test_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
1536        init_test(cx);
1537        cx.update(|cx| {
1538            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1539            settings.tool_permissions.tools.insert(
1540                "edit_file".into(),
1541                agent_settings::ToolRules {
1542                    default: Some(settings::ToolPermissionMode::Deny),
1543                    ..Default::default()
1544                },
1545            );
1546            agent_settings::AgentSettings::override_global(settings, cx);
1547        });
1548
1549        let fs = project::FakeFs::new(cx.executor());
1550        fs.insert_tree(
1551            path!("/root"),
1552            json!({
1553                "src": { "main.rs": "fn main() {}" }
1554            }),
1555        )
1556        .await;
1557        fs.insert_tree(
1558            path!("/outside"),
1559            json!({
1560                "config.txt": "old content"
1561            }),
1562        )
1563        .await;
1564        fs.create_symlink(
1565            path!("/root/link_to_external").as_ref(),
1566            PathBuf::from("/outside"),
1567        )
1568        .await
1569        .unwrap();
1570
1571        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1572        cx.executor().run_until_parked();
1573
1574        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1575        let context_server_registry =
1576            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1577        let model = Arc::new(FakeLanguageModel::default());
1578        let thread = cx.new(|cx| {
1579            Thread::new(
1580                project.clone(),
1581                cx.new(|_cx| ProjectContext::default()),
1582                context_server_registry,
1583                Templates::new(),
1584                Some(model),
1585                cx,
1586            )
1587        });
1588        let tool = Arc::new(EditFileTool::new(
1589            project.clone(),
1590            thread.downgrade(),
1591            language_registry,
1592            Templates::new(),
1593        ));
1594
1595        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1596        let result = cx
1597            .update(|cx| {
1598                tool.authorize(
1599                    &EditFileToolInput {
1600                        display_description: "edit through symlink".into(),
1601                        path: PathBuf::from("link_to_external/config.txt"),
1602                        mode: EditFileMode::Edit,
1603                    },
1604                    &stream_tx,
1605                    cx,
1606                )
1607            })
1608            .await;
1609
1610        assert!(result.is_err(), "Tool should fail when policy denies");
1611        assert!(
1612            !matches!(
1613                stream_rx.try_next(),
1614                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
1615            ),
1616            "Deny policy should not emit symlink authorization prompt",
1617        );
1618    }
1619
1620    #[gpui::test]
1621    async fn test_authorize_global_config(cx: &mut TestAppContext) {
1622        init_test(cx);
1623        let fs = project::FakeFs::new(cx.executor());
1624        fs.insert_tree("/project", json!({})).await;
1625        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1626        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1627        let context_server_registry =
1628            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1629        let model = Arc::new(FakeLanguageModel::default());
1630        let thread = cx.new(|cx| {
1631            Thread::new(
1632                project.clone(),
1633                cx.new(|_cx| ProjectContext::default()),
1634                context_server_registry,
1635                Templates::new(),
1636                Some(model.clone()),
1637                cx,
1638            )
1639        });
1640        let tool = Arc::new(EditFileTool::new(
1641            project.clone(),
1642            thread.downgrade(),
1643            language_registry,
1644            Templates::new(),
1645        ));
1646
1647        // Test global config paths - these should require confirmation if they exist and are outside the project
1648        let test_cases = vec![
1649            (
1650                "/etc/hosts",
1651                true,
1652                "System file should require confirmation",
1653            ),
1654            (
1655                "/usr/local/bin/script",
1656                true,
1657                "System bin file should require confirmation",
1658            ),
1659            (
1660                "project/normal_file.rs",
1661                false,
1662                "Normal project file should not require confirmation",
1663            ),
1664        ];
1665
1666        for (path, should_confirm, description) in test_cases {
1667            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1668            let auth = cx.update(|cx| {
1669                tool.authorize(
1670                    &EditFileToolInput {
1671                        display_description: "Edit file".into(),
1672                        path: path.into(),
1673                        mode: EditFileMode::Edit,
1674                    },
1675                    &stream_tx,
1676                    cx,
1677                )
1678            });
1679
1680            if should_confirm {
1681                stream_rx.expect_authorization().await;
1682            } else {
1683                auth.await.unwrap();
1684                assert!(
1685                    stream_rx.try_next().is_err(),
1686                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1687                    description,
1688                    path
1689                );
1690            }
1691        }
1692    }
1693
1694    #[gpui::test]
1695    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1696        init_test(cx);
1697        let fs = project::FakeFs::new(cx.executor());
1698
1699        // Create multiple worktree directories
1700        fs.insert_tree(
1701            "/workspace/frontend",
1702            json!({
1703                "src": {
1704                    "main.js": "console.log('frontend');"
1705                }
1706            }),
1707        )
1708        .await;
1709        fs.insert_tree(
1710            "/workspace/backend",
1711            json!({
1712                "src": {
1713                    "main.rs": "fn main() {}"
1714                }
1715            }),
1716        )
1717        .await;
1718        fs.insert_tree(
1719            "/workspace/shared",
1720            json!({
1721                ".zed": {
1722                    "settings.json": "{}"
1723                }
1724            }),
1725        )
1726        .await;
1727
1728        // Create project with multiple worktrees
1729        let project = Project::test(
1730            fs.clone(),
1731            [
1732                path!("/workspace/frontend").as_ref(),
1733                path!("/workspace/backend").as_ref(),
1734                path!("/workspace/shared").as_ref(),
1735            ],
1736            cx,
1737        )
1738        .await;
1739        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1740        let context_server_registry =
1741            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1742        let model = Arc::new(FakeLanguageModel::default());
1743        let thread = cx.new(|cx| {
1744            Thread::new(
1745                project.clone(),
1746                cx.new(|_cx| ProjectContext::default()),
1747                context_server_registry.clone(),
1748                Templates::new(),
1749                Some(model.clone()),
1750                cx,
1751            )
1752        });
1753        let tool = Arc::new(EditFileTool::new(
1754            project.clone(),
1755            thread.downgrade(),
1756            language_registry,
1757            Templates::new(),
1758        ));
1759
1760        // Test files in different worktrees
1761        let test_cases = vec![
1762            ("frontend/src/main.js", false, "File in first worktree"),
1763            ("backend/src/main.rs", false, "File in second worktree"),
1764            (
1765                "shared/.zed/settings.json",
1766                true,
1767                ".zed file in third worktree",
1768            ),
1769            ("/etc/hosts", true, "Absolute path outside all worktrees"),
1770            (
1771                "../outside/file.txt",
1772                true,
1773                "Relative path outside worktrees",
1774            ),
1775        ];
1776
1777        for (path, should_confirm, description) in test_cases {
1778            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1779            let auth = cx.update(|cx| {
1780                tool.authorize(
1781                    &EditFileToolInput {
1782                        display_description: "Edit file".into(),
1783                        path: path.into(),
1784                        mode: EditFileMode::Edit,
1785                    },
1786                    &stream_tx,
1787                    cx,
1788                )
1789            });
1790
1791            if should_confirm {
1792                stream_rx.expect_authorization().await;
1793            } else {
1794                auth.await.unwrap();
1795                assert!(
1796                    stream_rx.try_next().is_err(),
1797                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1798                    description,
1799                    path
1800                );
1801            }
1802        }
1803    }
1804
1805    #[gpui::test]
1806    async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1807        init_test(cx);
1808        let fs = project::FakeFs::new(cx.executor());
1809        fs.insert_tree(
1810            "/project",
1811            json!({
1812                ".zed": {
1813                    "settings.json": "{}"
1814                },
1815                "src": {
1816                    ".zed": {
1817                        "local.json": "{}"
1818                    }
1819                }
1820            }),
1821        )
1822        .await;
1823        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1824        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1825        let context_server_registry =
1826            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1827        let model = Arc::new(FakeLanguageModel::default());
1828        let thread = cx.new(|cx| {
1829            Thread::new(
1830                project.clone(),
1831                cx.new(|_cx| ProjectContext::default()),
1832                context_server_registry.clone(),
1833                Templates::new(),
1834                Some(model.clone()),
1835                cx,
1836            )
1837        });
1838        let tool = Arc::new(EditFileTool::new(
1839            project.clone(),
1840            thread.downgrade(),
1841            language_registry,
1842            Templates::new(),
1843        ));
1844
1845        // Test edge cases
1846        let test_cases = vec![
1847            // Empty path - find_project_path returns Some for empty paths
1848            ("", false, "Empty path is treated as project root"),
1849            // Root directory
1850            ("/", true, "Root directory should be outside project"),
1851            // Parent directory references - find_project_path resolves these
1852            (
1853                "project/../other",
1854                true,
1855                "Path with .. that goes outside of root directory",
1856            ),
1857            (
1858                "project/./src/file.rs",
1859                false,
1860                "Path with . should work normally",
1861            ),
1862            // Windows-style paths (if on Windows)
1863            #[cfg(target_os = "windows")]
1864            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1865            #[cfg(target_os = "windows")]
1866            ("project\\src\\main.rs", false, "Windows-style project path"),
1867        ];
1868
1869        for (path, should_confirm, description) in test_cases {
1870            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1871            let auth = cx.update(|cx| {
1872                tool.authorize(
1873                    &EditFileToolInput {
1874                        display_description: "Edit file".into(),
1875                        path: path.into(),
1876                        mode: EditFileMode::Edit,
1877                    },
1878                    &stream_tx,
1879                    cx,
1880                )
1881            });
1882
1883            cx.run_until_parked();
1884
1885            if should_confirm {
1886                stream_rx.expect_authorization().await;
1887            } else {
1888                assert!(
1889                    stream_rx.try_next().is_err(),
1890                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1891                    description,
1892                    path
1893                );
1894                auth.await.unwrap();
1895            }
1896        }
1897    }
1898
1899    #[gpui::test]
1900    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1901        init_test(cx);
1902        let fs = project::FakeFs::new(cx.executor());
1903        fs.insert_tree(
1904            "/project",
1905            json!({
1906                "existing.txt": "content",
1907                ".zed": {
1908                    "settings.json": "{}"
1909                }
1910            }),
1911        )
1912        .await;
1913        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1914        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1915        let context_server_registry =
1916            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1917        let model = Arc::new(FakeLanguageModel::default());
1918        let thread = cx.new(|cx| {
1919            Thread::new(
1920                project.clone(),
1921                cx.new(|_cx| ProjectContext::default()),
1922                context_server_registry.clone(),
1923                Templates::new(),
1924                Some(model.clone()),
1925                cx,
1926            )
1927        });
1928        let tool = Arc::new(EditFileTool::new(
1929            project.clone(),
1930            thread.downgrade(),
1931            language_registry,
1932            Templates::new(),
1933        ));
1934
1935        // Test different EditFileMode values
1936        let modes = vec![
1937            EditFileMode::Edit,
1938            EditFileMode::Create,
1939            EditFileMode::Overwrite,
1940        ];
1941
1942        for mode in modes {
1943            // Test .zed path with different modes
1944            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1945            let _auth = cx.update(|cx| {
1946                tool.authorize(
1947                    &EditFileToolInput {
1948                        display_description: "Edit settings".into(),
1949                        path: "project/.zed/settings.json".into(),
1950                        mode: mode.clone(),
1951                    },
1952                    &stream_tx,
1953                    cx,
1954                )
1955            });
1956
1957            stream_rx.expect_authorization().await;
1958
1959            // Test outside path with different modes
1960            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1961            let _auth = cx.update(|cx| {
1962                tool.authorize(
1963                    &EditFileToolInput {
1964                        display_description: "Edit file".into(),
1965                        path: "/outside/file.txt".into(),
1966                        mode: mode.clone(),
1967                    },
1968                    &stream_tx,
1969                    cx,
1970                )
1971            });
1972
1973            stream_rx.expect_authorization().await;
1974
1975            // Test normal path with different modes
1976            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1977            cx.update(|cx| {
1978                tool.authorize(
1979                    &EditFileToolInput {
1980                        display_description: "Edit file".into(),
1981                        path: "project/normal.txt".into(),
1982                        mode: mode.clone(),
1983                    },
1984                    &stream_tx,
1985                    cx,
1986                )
1987            })
1988            .await
1989            .unwrap();
1990            assert!(stream_rx.try_next().is_err());
1991        }
1992    }
1993
1994    #[gpui::test]
1995    async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1996        init_test(cx);
1997        let fs = project::FakeFs::new(cx.executor());
1998        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1999        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2000        let context_server_registry =
2001            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2002        let model = Arc::new(FakeLanguageModel::default());
2003        let thread = cx.new(|cx| {
2004            Thread::new(
2005                project.clone(),
2006                cx.new(|_cx| ProjectContext::default()),
2007                context_server_registry,
2008                Templates::new(),
2009                Some(model.clone()),
2010                cx,
2011            )
2012        });
2013        let tool = Arc::new(EditFileTool::new(
2014            project,
2015            thread.downgrade(),
2016            language_registry,
2017            Templates::new(),
2018        ));
2019
2020        cx.update(|cx| {
2021            // ...
2022            assert_eq!(
2023                tool.initial_title(
2024                    Err(json!({
2025                        "path": "src/main.rs",
2026                        "display_description": "",
2027                        "old_string": "old code",
2028                        "new_string": "new code"
2029                    })),
2030                    cx
2031                ),
2032                "src/main.rs"
2033            );
2034            assert_eq!(
2035                tool.initial_title(
2036                    Err(json!({
2037                        "path": "",
2038                        "display_description": "Fix error handling",
2039                        "old_string": "old code",
2040                        "new_string": "new code"
2041                    })),
2042                    cx
2043                ),
2044                "Fix error handling"
2045            );
2046            assert_eq!(
2047                tool.initial_title(
2048                    Err(json!({
2049                        "path": "src/main.rs",
2050                        "display_description": "Fix error handling",
2051                        "old_string": "old code",
2052                        "new_string": "new code"
2053                    })),
2054                    cx
2055                ),
2056                "src/main.rs"
2057            );
2058            assert_eq!(
2059                tool.initial_title(
2060                    Err(json!({
2061                        "path": "",
2062                        "display_description": "",
2063                        "old_string": "old code",
2064                        "new_string": "new code"
2065                    })),
2066                    cx
2067                ),
2068                DEFAULT_UI_TEXT
2069            );
2070            assert_eq!(
2071                tool.initial_title(Err(serde_json::Value::Null), cx),
2072                DEFAULT_UI_TEXT
2073            );
2074        });
2075    }
2076
2077    #[gpui::test]
2078    async fn test_diff_finalization(cx: &mut TestAppContext) {
2079        init_test(cx);
2080        let fs = project::FakeFs::new(cx.executor());
2081        fs.insert_tree("/", json!({"main.rs": ""})).await;
2082
2083        let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
2084        let languages = project.read_with(cx, |project, _cx| project.languages().clone());
2085        let context_server_registry =
2086            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2087        let model = Arc::new(FakeLanguageModel::default());
2088        let thread = cx.new(|cx| {
2089            Thread::new(
2090                project.clone(),
2091                cx.new(|_cx| ProjectContext::default()),
2092                context_server_registry.clone(),
2093                Templates::new(),
2094                Some(model.clone()),
2095                cx,
2096            )
2097        });
2098
2099        // Ensure the diff is finalized after the edit completes.
2100        {
2101            let tool = Arc::new(EditFileTool::new(
2102                project.clone(),
2103                thread.downgrade(),
2104                languages.clone(),
2105                Templates::new(),
2106            ));
2107            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2108            let edit = cx.update(|cx| {
2109                tool.run(
2110                    ToolInput::resolved(EditFileToolInput {
2111                        display_description: "Edit file".into(),
2112                        path: path!("/main.rs").into(),
2113                        mode: EditFileMode::Edit,
2114                    }),
2115                    stream_tx,
2116                    cx,
2117                )
2118            });
2119            stream_rx.expect_update_fields().await;
2120            let diff = stream_rx.expect_diff().await;
2121            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2122            cx.run_until_parked();
2123            model.end_last_completion_stream();
2124            edit.await.unwrap();
2125            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2126        }
2127
2128        // Ensure the diff is finalized if an error occurs while editing.
2129        {
2130            model.forbid_requests();
2131            let tool = Arc::new(EditFileTool::new(
2132                project.clone(),
2133                thread.downgrade(),
2134                languages.clone(),
2135                Templates::new(),
2136            ));
2137            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2138            let edit = cx.update(|cx| {
2139                tool.run(
2140                    ToolInput::resolved(EditFileToolInput {
2141                        display_description: "Edit file".into(),
2142                        path: path!("/main.rs").into(),
2143                        mode: EditFileMode::Edit,
2144                    }),
2145                    stream_tx,
2146                    cx,
2147                )
2148            });
2149            stream_rx.expect_update_fields().await;
2150            let diff = stream_rx.expect_diff().await;
2151            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2152            edit.await.unwrap_err();
2153            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2154            model.allow_requests();
2155        }
2156
2157        // Ensure the diff is finalized if the tool call gets dropped.
2158        {
2159            let tool = Arc::new(EditFileTool::new(
2160                project.clone(),
2161                thread.downgrade(),
2162                languages.clone(),
2163                Templates::new(),
2164            ));
2165            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2166            let edit = cx.update(|cx| {
2167                tool.run(
2168                    ToolInput::resolved(EditFileToolInput {
2169                        display_description: "Edit file".into(),
2170                        path: path!("/main.rs").into(),
2171                        mode: EditFileMode::Edit,
2172                    }),
2173                    stream_tx,
2174                    cx,
2175                )
2176            });
2177            stream_rx.expect_update_fields().await;
2178            let diff = stream_rx.expect_diff().await;
2179            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
2180            drop(edit);
2181            cx.run_until_parked();
2182            diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
2183        }
2184    }
2185
2186    #[gpui::test]
2187    async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
2188        init_test(cx);
2189
2190        let fs = project::FakeFs::new(cx.executor());
2191        fs.insert_tree(
2192            "/root",
2193            json!({
2194                "test.txt": "original content"
2195            }),
2196        )
2197        .await;
2198        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2199        let context_server_registry =
2200            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2201        let model = Arc::new(FakeLanguageModel::default());
2202        let thread = cx.new(|cx| {
2203            Thread::new(
2204                project.clone(),
2205                cx.new(|_cx| ProjectContext::default()),
2206                context_server_registry,
2207                Templates::new(),
2208                Some(model.clone()),
2209                cx,
2210            )
2211        });
2212        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2213
2214        // Initially, file_read_times should be empty
2215        let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
2216        assert!(is_empty, "file_read_times should start empty");
2217
2218        // Create read tool
2219        let read_tool = Arc::new(crate::ReadFileTool::new(
2220            thread.downgrade(),
2221            project.clone(),
2222            action_log,
2223        ));
2224
2225        // Read the file to record the read time
2226        cx.update(|cx| {
2227            read_tool.clone().run(
2228                ToolInput::resolved(crate::ReadFileToolInput {
2229                    path: "root/test.txt".to_string(),
2230                    start_line: None,
2231                    end_line: None,
2232                }),
2233                ToolCallEventStream::test().0,
2234                cx,
2235            )
2236        })
2237        .await
2238        .unwrap();
2239
2240        // Verify that file_read_times now contains an entry for the file
2241        let has_entry = thread.read_with(cx, |thread, _| {
2242            thread.file_read_times.len() == 1
2243                && thread
2244                    .file_read_times
2245                    .keys()
2246                    .any(|path| path.ends_with("test.txt"))
2247        });
2248        assert!(
2249            has_entry,
2250            "file_read_times should contain an entry after reading the file"
2251        );
2252
2253        // Read the file again - should update the entry
2254        cx.update(|cx| {
2255            read_tool.clone().run(
2256                ToolInput::resolved(crate::ReadFileToolInput {
2257                    path: "root/test.txt".to_string(),
2258                    start_line: None,
2259                    end_line: None,
2260                }),
2261                ToolCallEventStream::test().0,
2262                cx,
2263            )
2264        })
2265        .await
2266        .unwrap();
2267
2268        // Should still have exactly one entry
2269        let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
2270        assert!(
2271            has_one_entry,
2272            "file_read_times should still have one entry after re-reading"
2273        );
2274    }
2275
2276    fn init_test(cx: &mut TestAppContext) {
2277        cx.update(|cx| {
2278            let settings_store = SettingsStore::test(cx);
2279            cx.set_global(settings_store);
2280        });
2281    }
2282
2283    #[gpui::test]
2284    async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
2285        init_test(cx);
2286
2287        let fs = project::FakeFs::new(cx.executor());
2288        fs.insert_tree(
2289            "/root",
2290            json!({
2291                "test.txt": "original content"
2292            }),
2293        )
2294        .await;
2295        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2296        let context_server_registry =
2297            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2298        let model = Arc::new(FakeLanguageModel::default());
2299        let thread = cx.new(|cx| {
2300            Thread::new(
2301                project.clone(),
2302                cx.new(|_cx| ProjectContext::default()),
2303                context_server_registry,
2304                Templates::new(),
2305                Some(model.clone()),
2306                cx,
2307            )
2308        });
2309        let languages = project.read_with(cx, |project, _| project.languages().clone());
2310        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2311
2312        let read_tool = Arc::new(crate::ReadFileTool::new(
2313            thread.downgrade(),
2314            project.clone(),
2315            action_log,
2316        ));
2317        let edit_tool = Arc::new(EditFileTool::new(
2318            project.clone(),
2319            thread.downgrade(),
2320            languages,
2321            Templates::new(),
2322        ));
2323
2324        // Read the file first
2325        cx.update(|cx| {
2326            read_tool.clone().run(
2327                ToolInput::resolved(crate::ReadFileToolInput {
2328                    path: "root/test.txt".to_string(),
2329                    start_line: None,
2330                    end_line: None,
2331                }),
2332                ToolCallEventStream::test().0,
2333                cx,
2334            )
2335        })
2336        .await
2337        .unwrap();
2338
2339        // First edit should work
2340        let edit_result = {
2341            let edit_task = cx.update(|cx| {
2342                edit_tool.clone().run(
2343                    ToolInput::resolved(EditFileToolInput {
2344                        display_description: "First edit".into(),
2345                        path: "root/test.txt".into(),
2346                        mode: EditFileMode::Edit,
2347                    }),
2348                    ToolCallEventStream::test().0,
2349                    cx,
2350                )
2351            });
2352
2353            cx.executor().run_until_parked();
2354            model.send_last_completion_stream_text_chunk(
2355                "<old_text>original content</old_text><new_text>modified content</new_text>"
2356                    .to_string(),
2357            );
2358            model.end_last_completion_stream();
2359
2360            edit_task.await
2361        };
2362        assert!(
2363            edit_result.is_ok(),
2364            "First edit should succeed, got error: {:?}",
2365            edit_result.as_ref().err()
2366        );
2367
2368        // Second edit should also work because the edit updated the recorded read time
2369        let edit_result = {
2370            let edit_task = cx.update(|cx| {
2371                edit_tool.clone().run(
2372                    ToolInput::resolved(EditFileToolInput {
2373                        display_description: "Second edit".into(),
2374                        path: "root/test.txt".into(),
2375                        mode: EditFileMode::Edit,
2376                    }),
2377                    ToolCallEventStream::test().0,
2378                    cx,
2379                )
2380            });
2381
2382            cx.executor().run_until_parked();
2383            model.send_last_completion_stream_text_chunk(
2384                "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2385            );
2386            model.end_last_completion_stream();
2387
2388            edit_task.await
2389        };
2390        assert!(
2391            edit_result.is_ok(),
2392            "Second consecutive edit should succeed, got error: {:?}",
2393            edit_result.as_ref().err()
2394        );
2395    }
2396
2397    #[gpui::test]
2398    async fn test_external_modification_detected(cx: &mut TestAppContext) {
2399        init_test(cx);
2400
2401        let fs = project::FakeFs::new(cx.executor());
2402        fs.insert_tree(
2403            "/root",
2404            json!({
2405                "test.txt": "original content"
2406            }),
2407        )
2408        .await;
2409        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2410        let context_server_registry =
2411            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2412        let model = Arc::new(FakeLanguageModel::default());
2413        let thread = cx.new(|cx| {
2414            Thread::new(
2415                project.clone(),
2416                cx.new(|_cx| ProjectContext::default()),
2417                context_server_registry,
2418                Templates::new(),
2419                Some(model.clone()),
2420                cx,
2421            )
2422        });
2423        let languages = project.read_with(cx, |project, _| project.languages().clone());
2424        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2425
2426        let read_tool = Arc::new(crate::ReadFileTool::new(
2427            thread.downgrade(),
2428            project.clone(),
2429            action_log,
2430        ));
2431        let edit_tool = Arc::new(EditFileTool::new(
2432            project.clone(),
2433            thread.downgrade(),
2434            languages,
2435            Templates::new(),
2436        ));
2437
2438        // Read the file first
2439        cx.update(|cx| {
2440            read_tool.clone().run(
2441                ToolInput::resolved(crate::ReadFileToolInput {
2442                    path: "root/test.txt".to_string(),
2443                    start_line: None,
2444                    end_line: None,
2445                }),
2446                ToolCallEventStream::test().0,
2447                cx,
2448            )
2449        })
2450        .await
2451        .unwrap();
2452
2453        // Simulate external modification - advance time and save file
2454        cx.background_executor
2455            .advance_clock(std::time::Duration::from_secs(2));
2456        fs.save(
2457            path!("/root/test.txt").as_ref(),
2458            &"externally modified content".into(),
2459            language::LineEnding::Unix,
2460        )
2461        .await
2462        .unwrap();
2463
2464        // Reload the buffer to pick up the new mtime
2465        let project_path = project
2466            .read_with(cx, |project, cx| {
2467                project.find_project_path("root/test.txt", cx)
2468            })
2469            .expect("Should find project path");
2470        let buffer = project
2471            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2472            .await
2473            .unwrap();
2474        buffer
2475            .update(cx, |buffer, cx| buffer.reload(cx))
2476            .await
2477            .unwrap();
2478
2479        cx.executor().run_until_parked();
2480
2481        // Try to edit - should fail because file was modified externally
2482        let result = cx
2483            .update(|cx| {
2484                edit_tool.clone().run(
2485                    ToolInput::resolved(EditFileToolInput {
2486                        display_description: "Edit after external change".into(),
2487                        path: "root/test.txt".into(),
2488                        mode: EditFileMode::Edit,
2489                    }),
2490                    ToolCallEventStream::test().0,
2491                    cx,
2492                )
2493            })
2494            .await;
2495
2496        assert!(
2497            result.is_err(),
2498            "Edit should fail after external modification"
2499        );
2500        let error_msg = result.unwrap_err().to_string();
2501        assert!(
2502            error_msg.contains("has been modified since you last read it"),
2503            "Error should mention file modification, got: {}",
2504            error_msg
2505        );
2506    }
2507
2508    #[gpui::test]
2509    async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2510        init_test(cx);
2511
2512        let fs = project::FakeFs::new(cx.executor());
2513        fs.insert_tree(
2514            "/root",
2515            json!({
2516                "test.txt": "original content"
2517            }),
2518        )
2519        .await;
2520        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2521        let context_server_registry =
2522            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2523        let model = Arc::new(FakeLanguageModel::default());
2524        let thread = cx.new(|cx| {
2525            Thread::new(
2526                project.clone(),
2527                cx.new(|_cx| ProjectContext::default()),
2528                context_server_registry,
2529                Templates::new(),
2530                Some(model.clone()),
2531                cx,
2532            )
2533        });
2534        let languages = project.read_with(cx, |project, _| project.languages().clone());
2535        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2536
2537        let read_tool = Arc::new(crate::ReadFileTool::new(
2538            thread.downgrade(),
2539            project.clone(),
2540            action_log,
2541        ));
2542        let edit_tool = Arc::new(EditFileTool::new(
2543            project.clone(),
2544            thread.downgrade(),
2545            languages,
2546            Templates::new(),
2547        ));
2548
2549        // Read the file first
2550        cx.update(|cx| {
2551            read_tool.clone().run(
2552                ToolInput::resolved(crate::ReadFileToolInput {
2553                    path: "root/test.txt".to_string(),
2554                    start_line: None,
2555                    end_line: None,
2556                }),
2557                ToolCallEventStream::test().0,
2558                cx,
2559            )
2560        })
2561        .await
2562        .unwrap();
2563
2564        // Open the buffer and make it dirty by editing without saving
2565        let project_path = project
2566            .read_with(cx, |project, cx| {
2567                project.find_project_path("root/test.txt", cx)
2568            })
2569            .expect("Should find project path");
2570        let buffer = project
2571            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2572            .await
2573            .unwrap();
2574
2575        // Make an in-memory edit to the buffer (making it dirty)
2576        buffer.update(cx, |buffer, cx| {
2577            let end_point = buffer.max_point();
2578            buffer.edit([(end_point..end_point, " added text")], None, cx);
2579        });
2580
2581        // Verify buffer is dirty
2582        let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2583        assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2584
2585        // Try to edit - should fail because buffer has unsaved changes
2586        let result = cx
2587            .update(|cx| {
2588                edit_tool.clone().run(
2589                    ToolInput::resolved(EditFileToolInput {
2590                        display_description: "Edit with dirty buffer".into(),
2591                        path: "root/test.txt".into(),
2592                        mode: EditFileMode::Edit,
2593                    }),
2594                    ToolCallEventStream::test().0,
2595                    cx,
2596                )
2597            })
2598            .await;
2599
2600        assert!(result.is_err(), "Edit should fail when buffer is dirty");
2601        let error_msg = result.unwrap_err().to_string();
2602        assert!(
2603            error_msg.contains("This file has unsaved changes."),
2604            "Error should mention unsaved changes, got: {}",
2605            error_msg
2606        );
2607        assert!(
2608            error_msg.contains("keep or discard"),
2609            "Error should ask whether to keep or discard changes, got: {}",
2610            error_msg
2611        );
2612        // Since save_file and restore_file_from_disk tools aren't added to the thread,
2613        // the error message should ask the user to manually save or revert
2614        assert!(
2615            error_msg.contains("save or revert the file manually"),
2616            "Error should ask user to manually save or revert when tools aren't available, got: {}",
2617            error_msg
2618        );
2619    }
2620
2621    #[gpui::test]
2622    async fn test_sensitive_settings_kind_detects_nonexistent_subdirectory(
2623        cx: &mut TestAppContext,
2624    ) {
2625        let fs = project::FakeFs::new(cx.executor());
2626        let config_dir = paths::config_dir();
2627        fs.insert_tree(&*config_dir.to_string_lossy(), json!({}))
2628            .await;
2629        let path = config_dir.join("nonexistent_subdir_xyz").join("evil.json");
2630        assert!(
2631            matches!(
2632                sensitive_settings_kind(&path, fs.as_ref()).await,
2633                Some(SensitiveSettingsKind::Global)
2634            ),
2635            "Path in non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2636            path
2637        );
2638    }
2639
2640    #[gpui::test]
2641    async fn test_sensitive_settings_kind_detects_deeply_nested_nonexistent_subdirectory(
2642        cx: &mut TestAppContext,
2643    ) {
2644        let fs = project::FakeFs::new(cx.executor());
2645        let config_dir = paths::config_dir();
2646        fs.insert_tree(&*config_dir.to_string_lossy(), json!({}))
2647            .await;
2648        let path = config_dir.join("a").join("b").join("c").join("evil.json");
2649        assert!(
2650            matches!(
2651                sensitive_settings_kind(&path, fs.as_ref()).await,
2652                Some(SensitiveSettingsKind::Global)
2653            ),
2654            "Path in deeply nested non-existent subdirectory of config dir should be detected as sensitive: {:?}",
2655            path
2656        );
2657    }
2658
2659    #[gpui::test]
2660    async fn test_sensitive_settings_kind_returns_none_for_non_config_path(
2661        cx: &mut TestAppContext,
2662    ) {
2663        let fs = project::FakeFs::new(cx.executor());
2664        let path = PathBuf::from("/tmp/not_a_config_dir/some_file.json");
2665        assert!(
2666            sensitive_settings_kind(&path, fs.as_ref()).await.is_none(),
2667            "Path outside config dir should not be detected as sensitive: {:?}",
2668            path
2669        );
2670    }
2671}