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