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