edit_file_tool.rs

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