edit_file_tool.rs

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