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