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