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