Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:33:22.586Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

edit_file_tool.rs

3132 lines · 116.8 KB · rust
1use super::deserialize_maybe_stringified;
2pub(crate) use super::edit_session::PartialEdit;
3pub use super::edit_session::{Edit, EditSessionOutput as EditFileToolOutput};
4use super::edit_session::{
5    EditSession, EditSessionContext, EditSessionMode, EditSessionResult,
6    initial_title_from_partial_path, run_session,
7};
8use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload};
9use action_log::ActionLog;
10use agent_client_protocol::schema::v1 as acp;
11use anyhow::Result;
12use futures::FutureExt as _;
13use gpui::{App, AsyncApp, Entity, Task, WeakEntity};
14use language::LanguageRegistry;
15use project::Project;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::path::PathBuf;
19use std::sync::Arc;
20use ui::SharedString;
21
22const DEFAULT_UI_TEXT: &str = "Editing file";
23
24/// This is a tool for applying edits to an existing file.
25///
26/// Before using this tool, use the `read_file` tool to understand the file's contents and context.
27/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead.
28///
29/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
30///
31/// `read_file` prefixes each line of its output with a line number right-aligned in a
32/// 6-character field followed by a single tab, then the line's actual content. When you
33/// derive `old_text` or `new_text` from that output, strip this prefix and keep only what
34/// comes after the tab, preserving the original indentation (tabs and spaces) exactly.
35/// Never include any part of the line number prefix in `old_text` or `new_text`.
36#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
37pub struct EditFileToolInput {
38    /// The full path of the file to edit in the project.
39    ///
40    /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories, unless it's a global agent skill under `~/.agents/skills`.
41    ///
42    /// The following examples assume we have two root directories in the project:
43    /// - /a/b/backend
44    /// - /c/d/frontend
45    ///
46    /// <example>
47    /// `backend/src/main.rs`
48    ///
49    /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
50    /// </example>
51    ///
52    /// <example>
53    /// `frontend/db.js`
54    /// </example>
55    ///
56    /// <example>
57    /// To edit a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`.
58    /// </example>
59    pub path: PathBuf,
60
61    /// List of edit operations to apply sequentially.
62    /// Each edit finds `old_text` in the file and replaces it with `new_text`.
63    #[serde(deserialize_with = "deserialize_maybe_stringified")]
64    pub edits: Vec<Edit>,
65}
66
67#[derive(Clone, Default, Debug, Deserialize)]
68struct EditFileToolPartialInput {
69    #[serde(default)]
70    path: Option<String>,
71    #[serde(default, deserialize_with = "deserialize_maybe_stringified")]
72    edits: Option<Vec<PartialEdit>>,
73}
74
75pub struct EditFileTool {
76    session_context: Arc<EditSessionContext>,
77}
78
79impl EditFileTool {
80    pub fn new(
81        project: Entity<Project>,
82        thread: WeakEntity<Thread>,
83        action_log: Entity<ActionLog>,
84        language_registry: Arc<LanguageRegistry>,
85    ) -> Self {
86        Self {
87            session_context: Arc::new(EditSessionContext::new(
88                project,
89                thread,
90                action_log,
91                language_registry,
92            )),
93        }
94    }
95
96    #[cfg(test)]
97    fn authorize(
98        &self,
99        path: &PathBuf,
100        event_stream: &ToolCallEventStream,
101        cx: &mut App,
102    ) -> Task<Result<()>> {
103        self.session_context
104            .authorize(Self::NAME, path, event_stream, cx)
105    }
106
107    async fn process_streaming_edits(
108        &self,
109        input: &mut ToolInput<EditFileToolInput>,
110        event_stream: &ToolCallEventStream,
111        cx: &mut AsyncApp,
112    ) -> EditSessionResult {
113        let mut session: Option<EditSession> = None;
114        let mut last_path: Option<String> = None;
115
116        loop {
117            futures::select! {
118                payload = input.next().fuse() => {
119                    match payload {
120                        Ok(payload) => match payload {
121                            ToolInputPayload::Partial(partial) => {
122                                if let Ok(parsed) = serde_json::from_value::<EditFileToolPartialInput>(partial) {
123                                    let path_complete = parsed.path.is_some()
124                                        && parsed.path.as_ref() == last_path.as_ref();
125
126                                    last_path = parsed.path.clone();
127
128                                    if session.is_none()
129                                        && path_complete
130                                        && let Some(path) = parsed.path.as_ref()
131                                    {
132                                        match EditSession::new(
133                                            PathBuf::from(path),
134                                            EditSessionMode::Edit,
135                                            Self::NAME,
136                                            self.session_context.clone(),
137                                            event_stream,
138                                            cx,
139                                        )
140                                        .await
141                                        {
142                                            Ok(created_session) => session = Some(created_session),
143                                            Err(error) => {
144                                                log::error!("Failed to create edit session: {}", error);
145                                                return EditSessionResult::Failed {
146                                                    error,
147                                                    session: None,
148                                                };
149                                            }
150                                        }
151                                    }
152
153                                    if let Some(current_session) = &mut session
154                                        && let Err(error) = current_session.process_edit(parsed.edits.as_deref(), event_stream, cx)
155                                    {
156                                        log::error!("Failed to process edit: {}", error);
157                                        return EditSessionResult::Failed { error, session };
158                                    }
159                                }
160                            }
161                            ToolInputPayload::Full(full_input) => {
162                                let mut session = if let Some(session) = session {
163                                    session
164                                } else {
165                                    match EditSession::new(
166                                        full_input.path.clone(),
167                                        EditSessionMode::Edit,
168                                        Self::NAME,
169                                        self.session_context.clone(),
170                                        event_stream,
171                                        cx,
172                                    )
173                                    .await
174                                    {
175                                        Ok(created_session) => created_session,
176                                        Err(error) => {
177                                            log::error!("Failed to create edit session: {}", error);
178                                            return EditSessionResult::Failed {
179                                                error,
180                                                session: None,
181                                            };
182                                        }
183                                    }
184                                };
185
186                                return match session.finalize_edit(full_input.edits, event_stream, cx).await {
187                                    Ok(()) => EditSessionResult::Completed(session),
188                                    Err(error) => {
189                                        log::error!("Failed to finalize edit: {}", error);
190                                        EditSessionResult::Failed {
191                                            error,
192                                            session: Some(session),
193                                        }
194                                    }
195                                };
196                            }
197                            ToolInputPayload::InvalidJson { error_message } => {
198                                log::error!("Received invalid JSON: {error_message}");
199                                return EditSessionResult::Failed {
200                                    error: error_message,
201                                    session,
202                                };
203                            }
204                        },
205                        Err(error) => {
206                            return EditSessionResult::Failed {
207                                error: error.to_string(),
208                                session,
209                            };
210                        }
211                    }
212                }
213                _ = event_stream.cancelled_by_user().fuse() => {
214                    return EditSessionResult::Failed {
215                        error: "Edit cancelled by user".to_string(),
216                        session,
217                    };
218                }
219            }
220        }
221    }
222}
223
224impl AgentTool for EditFileTool {
225    type Input = EditFileToolInput;
226    type Output = EditFileToolOutput;
227
228    const NAME: &'static str = "edit_file";
229
230    fn supports_input_streaming() -> bool {
231        true
232    }
233
234    fn kind() -> acp::ToolKind {
235        acp::ToolKind::Edit
236    }
237
238    fn initial_title(
239        &self,
240        input: Result<Self::Input, serde_json::Value>,
241        cx: &mut App,
242    ) -> SharedString {
243        match input {
244            Ok(input) => {
245                self.session_context
246                    .initial_title_from_path(&input.path, DEFAULT_UI_TEXT, cx)
247            }
248            Err(raw_input) => initial_title_from_partial_path::<EditFileToolPartialInput>(
249                &self.session_context,
250                raw_input,
251                |partial| partial.path.clone(),
252                DEFAULT_UI_TEXT,
253                cx,
254            ),
255        }
256    }
257
258    fn run(
259        self: Arc<Self>,
260        mut input: ToolInput<Self::Input>,
261        event_stream: ToolCallEventStream,
262        cx: &mut App,
263    ) -> Task<Result<Self::Output, Self::Output>> {
264        cx.spawn(async move |cx: &mut AsyncApp| {
265            run_session(
266                self.process_streaming_edits(&mut input, &event_stream, cx)
267                    .await,
268                &event_stream,
269                cx,
270            )
271            .await
272        })
273    }
274
275    fn replay(
276        &self,
277        _input: Self::Input,
278        output: Self::Output,
279        event_stream: ToolCallEventStream,
280        cx: &mut App,
281    ) -> Result<()> {
282        self.session_context.replay_output(output, event_stream, cx)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::{ContextServerRegistry, Templates, ToolInputSender};
290    use fs::Fs as _;
291    use gpui::{AppContext as _, TestAppContext, UpdateGlobal};
292    use language_model::fake_provider::FakeLanguageModel;
293    use project::ProjectPath;
294    use prompt_store::ProjectContext;
295    use serde_json::json;
296    use settings::Settings;
297    use settings::SettingsStore;
298    use util::path;
299    use util::rel_path::{RelPath, rel_path};
300
301    #[gpui::test]
302    async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) {
303        let (edit_tool, _project, _action_log, _fs, _thread) =
304            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
305        let result = cx
306            .update(|cx| {
307                edit_tool.clone().run(
308                    ToolInput::resolved(EditFileToolInput {
309                        path: "root/file.txt".into(),
310                        edits: vec![Edit {
311                            old_text: "line 2".into(),
312                            new_text: "modified line 2".into(),
313                        }],
314                    }),
315                    ToolCallEventStream::test().0,
316                    cx,
317                )
318            })
319            .await;
320
321        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
322            panic!("expected success");
323        };
324        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
325    }
326
327    #[gpui::test]
328    async fn test_streaming_edit_exact_fragments(cx: &mut TestAppContext) {
329        let content = concat!(
330            "fn spaces() {\n",
331            "    spaces_old();\n",
332            "}\n",
333            "fn tabs() {\n",
334            "\ttabs_old();\n",
335            "}\n",
336            "controls: keyboard WASD, voxel-based\n",
337            "prefix OLD suffix\n",
338            "foo suffix\n",
339            "foo\n",
340        );
341        let (edit_tool, _project, _action_log, _fs, _thread) =
342            setup_test(cx, json!({"file.rs": content})).await;
343        let result = cx
344            .update(|cx| {
345                edit_tool.clone().run(
346                    ToolInput::resolved(EditFileToolInput {
347                        path: "root/file.rs".into(),
348                        edits: vec![
349                            Edit {
350                                old_text: "keyboard WASD, voxel-based".into(),
351                                new_text: "arrow keys".into(),
352                            },
353                            Edit {
354                                old_text: "spaces_old();".into(),
355                                new_text: "spaces_new();\nspaces_more();".into(),
356                            },
357                            Edit {
358                                old_text: "tabs_old();".into(),
359                                new_text: "tabs_new();\ntabs_more();".into(),
360                            },
361                            Edit {
362                                old_text: "OLD".into(),
363                                new_text: "NEW\n".into(),
364                            },
365                            Edit {
366                                old_text: "foo\n".into(),
367                                new_text: "bar\n".into(),
368                            },
369                        ],
370                    }),
371                    ToolCallEventStream::test().0,
372                    cx,
373                )
374            })
375            .await;
376
377        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
378            panic!("expected success");
379        };
380        assert_eq!(
381            new_text,
382            concat!(
383                "fn spaces() {\n",
384                "    spaces_new();\n",
385                "    spaces_more();\n",
386                "}\n",
387                "fn tabs() {\n",
388                "\ttabs_new();\n",
389                "\ttabs_more();\n",
390                "}\n",
391                "controls: arrow keys\n",
392                "prefix NEW\n",
393                " suffix\n",
394                "foo suffix\n",
395                "bar\n",
396            )
397        );
398    }
399
400    #[gpui::test]
401    async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) {
402        // Reproduces https://github.com/zed-industries/zed/issues/60302: the
403        // first line of the multi-line `old_text` omits its leading
404        // indentation while subsequent lines include theirs, so the indent
405        // delta computed from the first line must not be applied to the
406        // following lines. `old_text` also omits the `self.extra` line, so
407        // the query lines don't correspond one-to-one to the matched buffer
408        // rows and the indent pairing must follow the fuzzy match's
409        // alignment instead of assuming equal line counts.
410        let content = concat!(
411            "class Outer:\n",
412            "    def method(self):\n",
413            "        self.kept = \"unchanged\"\n",
414            "        self.target_a = \"before\"\n",
415            "        self.extra = \"row\"\n",
416            "        self.target_b = \"before\"\n",
417            "        self.target_c = \"before\"\n",
418            "        self.target_d = \"before\"\n",
419            "        self.kept_2 = \"unchanged\"\n",
420        );
421        let (edit_tool, _project, _action_log, _fs, _thread) =
422            setup_test(cx, json!({"file.py": content})).await;
423        let result = cx
424            .update(|cx| {
425                edit_tool.clone().run(
426                    ToolInput::resolved(EditFileToolInput {
427                        path: "root/file.py".into(),
428                        edits: vec![Edit {
429                            old_text: concat!(
430                                "self.target_a = \"before\"\n",
431                                "        self.target_b = \"before\"\n",
432                                "        self.target_c = \"before\"\n",
433                                "        self.target_d = \"before\"",
434                            )
435                            .into(),
436                            new_text: concat!(
437                                "self.target_a = \"after\"\n",
438                                "        self.target_b = \"after\"\n",
439                                "        self.target_c = \"after\"\n",
440                                "        self.target_d = \"after\"",
441                            )
442                            .into(),
443                        }],
444                    }),
445                    ToolCallEventStream::test().0,
446                    cx,
447                )
448            })
449            .await;
450
451        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
452            panic!("expected success");
453        };
454        // The matched range includes the `self.extra` row, so it is replaced
455        // along with the rest of the match.
456        assert_eq!(
457            new_text,
458            concat!(
459                "class Outer:\n",
460                "    def method(self):\n",
461                "        self.kept = \"unchanged\"\n",
462                "        self.target_a = \"after\"\n",
463                "        self.target_b = \"after\"\n",
464                "        self.target_c = \"after\"\n",
465                "        self.target_d = \"after\"\n",
466                "        self.kept_2 = \"unchanged\"\n",
467            )
468        );
469    }
470
471    #[gpui::test]
472    async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) {
473        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(
474            cx,
475            json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
476        )
477        .await;
478        let result = cx
479            .update(|cx| {
480                edit_tool.clone().run(
481                    ToolInput::resolved(EditFileToolInput {
482                        path: "root/file.txt".into(),
483                        edits: vec![
484                            Edit {
485                                old_text: "line 5".into(),
486                                new_text: "modified line 5".into(),
487                            },
488                            Edit {
489                                old_text: "line 1".into(),
490                                new_text: "modified line 1".into(),
491                            },
492                        ],
493                    }),
494                    ToolCallEventStream::test().0,
495                    cx,
496                )
497            })
498            .await;
499
500        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
501            panic!("expected success");
502        };
503        assert_eq!(
504            new_text,
505            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
506        );
507    }
508
509    #[gpui::test]
510    async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) {
511        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(
512            cx,
513            json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
514        )
515        .await;
516        let result = cx
517            .update(|cx| {
518                edit_tool.clone().run(
519                    ToolInput::resolved(EditFileToolInput {
520                        path: "root/file.txt".into(),
521                        edits: vec![
522                            Edit {
523                                old_text: "line 2".into(),
524                                new_text: "modified line 2".into(),
525                            },
526                            Edit {
527                                old_text: "line 3".into(),
528                                new_text: "modified line 3".into(),
529                            },
530                        ],
531                    }),
532                    ToolCallEventStream::test().0,
533                    cx,
534                )
535            })
536            .await;
537
538        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
539            panic!("expected success");
540        };
541        assert_eq!(
542            new_text,
543            "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n"
544        );
545    }
546
547    #[gpui::test]
548    async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) {
549        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(
550            cx,
551            json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
552        )
553        .await;
554        let result = cx
555            .update(|cx| {
556                edit_tool.clone().run(
557                    ToolInput::resolved(EditFileToolInput {
558                        path: "root/file.txt".into(),
559                        edits: vec![
560                            Edit {
561                                old_text: "line 1".into(),
562                                new_text: "modified line 1".into(),
563                            },
564                            Edit {
565                                old_text: "line 5".into(),
566                                new_text: "modified line 5".into(),
567                            },
568                        ],
569                    }),
570                    ToolCallEventStream::test().0,
571                    cx,
572                )
573            })
574            .await;
575
576        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
577            panic!("expected success");
578        };
579        assert_eq!(
580            new_text,
581            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
582        );
583    }
584
585    #[gpui::test]
586    async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) {
587        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await;
588        let result = cx
589            .update(|cx| {
590                edit_tool.clone().run(
591                    ToolInput::resolved(EditFileToolInput {
592                        path: "root/nonexistent_file.txt".into(),
593                        edits: vec![Edit {
594                            old_text: "foo".into(),
595                            new_text: "bar".into(),
596                        }],
597                    }),
598                    ToolCallEventStream::test().0,
599                    cx,
600                )
601            })
602            .await;
603
604        let EditFileToolOutput::Error {
605            error,
606            diff,
607            input_path,
608        } = result.unwrap_err()
609        else {
610            panic!("expected error");
611        };
612        assert_eq!(error, "Can't edit file: path not found");
613        assert!(diff.is_empty());
614        assert_eq!(input_path, None);
615    }
616
617    #[gpui::test]
618    async fn test_streaming_edit_global_skill_file(cx: &mut TestAppContext) {
619        init_test(cx);
620
621        let fs = project::FakeFs::new(cx.executor());
622        fs.insert_tree(path!("/root"), json!({})).await;
623        let skill_dir = agent_skills::global_skills_dir().join("my-skill");
624        fs.insert_tree(&skill_dir, json!({ "SKILL.md": "old content\n" }))
625            .await;
626        let (edit_tool, _project, _action_log, fs, _thread) =
627            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
628
629        let input_path = PathBuf::from("~")
630            .join(".agents")
631            .join("skills")
632            .join("my-skill")
633            .join("SKILL.md");
634        let skill_file = agent_skills::global_skills_dir()
635            .join("my-skill")
636            .join("SKILL.md");
637
638        let (event_stream, mut event_rx) = ToolCallEventStream::test();
639        let task = cx.update(|cx| {
640            edit_tool.clone().run(
641                ToolInput::resolved(EditFileToolInput {
642                    path: input_path,
643                    edits: vec![Edit {
644                        old_text: "old content".into(),
645                        new_text: "new content".into(),
646                    }],
647                }),
648                event_stream,
649                cx,
650            )
651        });
652
653        event_rx.expect_update_fields().await;
654        let auth = event_rx.expect_authorization().await;
655        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
656        assert!(
657            title.contains("agent skills"),
658            "Authorization title should mention agent skills, got: {title}",
659        );
660        auth.response
661            .send(acp_thread::SelectedPermissionOutcome::new(
662                acp::PermissionOptionId::new("allow"),
663                acp::PermissionOptionKind::AllowOnce,
664            ))
665            .expect("authorization response should send");
666
667        let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
668            panic!("expected success");
669        };
670        assert_eq!(new_text, "new content\n");
671        assert_eq!(fs.load(&skill_file).await.unwrap(), "new content\n");
672    }
673
674    #[gpui::test]
675    async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) {
676        let (edit_tool, _project, _action_log, _fs, _thread) =
677            setup_test(cx, json!({"file.txt": "hello world"})).await;
678        let result = cx
679            .update(|cx| {
680                edit_tool.clone().run(
681                    ToolInput::resolved(EditFileToolInput {
682                        path: "root/file.txt".into(),
683                        edits: vec![Edit {
684                            old_text: "nonexistent text that is not in the file".into(),
685                            new_text: "replacement".into(),
686                        }],
687                    }),
688                    ToolCallEventStream::test().0,
689                    cx,
690                )
691            })
692            .await;
693
694        let EditFileToolOutput::Error { error, .. } = result.unwrap_err() else {
695            panic!("expected error");
696        };
697        assert!(
698            error.contains("Could not find matching text"),
699            "Expected error containing 'Could not find matching text' but got: {error}"
700        );
701    }
702
703    #[gpui::test]
704    async fn test_streaming_edit_rejects_overlapping_matches(cx: &mut TestAppContext) {
705        let (edit_tool, _project, _action_log, _fs, _thread) =
706            setup_test(cx, json!({"file.txt": "aaaaa"})).await;
707        let result = cx
708            .update(|cx| {
709                edit_tool.clone().run(
710                    ToolInput::resolved(EditFileToolInput {
711                        path: "root/file.txt".into(),
712                        edits: vec![Edit {
713                            old_text: "aaaa".into(),
714                            new_text: "replacement".into(),
715                        }],
716                    }),
717                    ToolCallEventStream::test().0,
718                    cx,
719                )
720            })
721            .await;
722
723        let EditFileToolOutput::Error { error, diff, .. } = result.unwrap_err() else {
724            panic!("expected error");
725        };
726        assert!(error.contains("matched multiple locations"));
727        assert!(diff.is_empty());
728    }
729
730    /// When the edit fails after a session is created but before any edits are
731    /// actually applied (e.g., the first `old_text` doesn't match), the empty
732    /// diff placeholder in the UI should be replaced with the error message.
733    #[gpui::test]
734    async fn test_streaming_edit_surfaces_error_when_no_edits_applied(cx: &mut TestAppContext) {
735        async fn find_first_text_content_in_events(
736            receiver: &mut crate::ToolCallEventStreamReceiver,
737        ) -> Option<String> {
738            use futures::StreamExt as _;
739            while let Some(event) = receiver.next().await {
740                let Ok(crate::ThreadEvent::ToolCallUpdate(
741                    acp_thread::ToolCallUpdate::UpdateFields(update),
742                )) = event
743                else {
744                    continue;
745                };
746                let Some(content) = update.fields.content else {
747                    continue;
748                };
749                for item in content {
750                    if let acp::ToolCallContent::Content(c) = item
751                        && let acp::ContentBlock::Text(text) = c.content
752                    {
753                        return Some(text.text);
754                    }
755                }
756            }
757            None
758        }
759
760        let (edit_tool, _project, _action_log, _fs, _thread) =
761            setup_test(cx, json!({"file.txt": "hello world"})).await;
762        let (event_stream, mut receiver) = ToolCallEventStream::test();
763        let task = cx.update(|cx| {
764            edit_tool.clone().run(
765                ToolInput::resolved(EditFileToolInput {
766                    path: "root/file.txt".into(),
767                    edits: vec![Edit {
768                        old_text: "nonexistent text that is not in the file".into(),
769                        new_text: "replacement".into(),
770                    }],
771                }),
772                event_stream,
773                cx,
774            )
775        });
776
777        let EditFileToolOutput::Error { error, diff, .. } = task.await.unwrap_err() else {
778            panic!("expected error");
779        };
780        assert!(
781            diff.is_empty(),
782            "sanity check: no edits should have been applied",
783        );
784
785        let content_text = find_first_text_content_in_events(&mut receiver).await;
786        assert_eq!(
787            content_text.as_deref(),
788            Some(error.as_str()),
789            "expected the failure message to be surfaced as tool call content",
790        );
791    }
792
793    #[gpui::test]
794    async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {
795        let (edit_tool, _project, _action_log, _fs, _thread) =
796            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
797        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
798        let (event_stream, _receiver) = ToolCallEventStream::test();
799        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
800
801        // Send partials simulating LLM streaming: description first, then path, then mode
802        sender.send_partial(json!({}));
803        cx.run_until_parked();
804
805        sender.send_partial(json!({
806            "path": "root/file.txt"
807        }));
808        cx.run_until_parked();
809
810        // Path is NOT yet complete because mode hasn't appeared — no buffer open yet
811        sender.send_partial(json!({
812            "path": "root/file.txt",
813        }));
814        cx.run_until_parked();
815
816        // Now send the final complete input
817        sender.send_full(json!({
818            "path": "root/file.txt",
819            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
820        }));
821
822        let result = task.await;
823        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
824            panic!("expected success");
825        };
826        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
827    }
828
829    #[gpui::test]
830    async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) {
831        let (edit_tool, _project, _action_log, _fs, _thread) =
832            setup_test(cx, json!({"file.txt": "hello world"})).await;
833        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
834        let (event_stream, _receiver, mut cancellation_tx) =
835            ToolCallEventStream::test_with_cancellation();
836        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
837
838        // Send a partial
839        sender.send_partial(json!({}));
840        cx.run_until_parked();
841
842        // Cancel during streaming
843        ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx);
844        cx.run_until_parked();
845
846        // The sender is still alive so the partial loop should detect cancellation
847        // We need to drop the sender to also unblock recv() if the loop didn't catch it
848        drop(sender);
849
850        let result = task.await;
851        let EditFileToolOutput::Error { error, .. } = result.unwrap_err() else {
852            panic!("expected error");
853        };
854        assert!(
855            error.contains("cancelled"),
856            "Expected cancellation error but got: {error}"
857        );
858    }
859
860    #[gpui::test]
861    async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) {
862        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(
863            cx,
864            json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
865        )
866        .await;
867        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
868        let (event_stream, _receiver) = ToolCallEventStream::test();
869        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
870
871        // Simulate fine-grained streaming of the JSON
872        sender.send_partial(json!({}));
873        cx.run_until_parked();
874
875        sender.send_partial(json!({
876            "path": "root/file.txt"
877        }));
878        cx.run_until_parked();
879
880        sender.send_partial(json!({
881            "path": "root/file.txt",
882        }));
883        cx.run_until_parked();
884
885        sender.send_partial(json!({
886            "path": "root/file.txt",
887            "edits": [{"old_text": "line 1"}]
888        }));
889        cx.run_until_parked();
890
891        sender.send_partial(json!({
892            "path": "root/file.txt",
893            "edits": [
894                {"old_text": "line 1", "new_text": "modified line 1"},
895                {"old_text": "line 5"}
896            ]
897        }));
898        cx.run_until_parked();
899
900        // Send final complete input
901        sender.send_full(json!({
902            "path": "root/file.txt",
903            "edits": [
904                {"old_text": "line 1", "new_text": "modified line 1"},
905                {"old_text": "line 5", "new_text": "modified line 5"}
906            ]
907        }));
908
909        let result = task.await;
910        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
911            panic!("expected success");
912        };
913        assert_eq!(
914            new_text,
915            "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
916        );
917    }
918
919    #[gpui::test]
920    async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) {
921        let (edit_tool, _project, _action_log, _fs, _thread) =
922            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
923        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
924        let (event_stream, _receiver) = ToolCallEventStream::test();
925        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
926
927        // Send final immediately with no partials (simulates non-streaming path)
928        sender.send_full(json!({
929            "path": "root/file.txt",
930            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
931        }));
932
933        let result = task.await;
934        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
935            panic!("expected success");
936        };
937        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
938    }
939
940    #[gpui::test]
941    async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) {
942        let (edit_tool, project, _action_log, _fs, _thread) = setup_test(
943            cx,
944            json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
945        )
946        .await;
947        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
948        let (event_stream, _receiver) = ToolCallEventStream::test();
949        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
950
951        // Stream description, path, mode
952        sender.send_partial(json!({}));
953        cx.run_until_parked();
954
955        sender.send_partial(json!({
956            "path": "root/file.txt",
957        }));
958        cx.run_until_parked();
959
960        // First edit starts streaming (old_text only, still in progress)
961        sender.send_partial(json!({
962            "path": "root/file.txt",
963            "edits": [{"old_text": "line 1"}]
964        }));
965        cx.run_until_parked();
966
967        // Buffer should not have changed yet — the first edit is still in progress
968        // (no second edit has appeared to prove the first is complete)
969        let buffer_text = project.update(cx, |project, cx| {
970            let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
971            project_path.and_then(|pp| {
972                project
973                    .get_open_buffer(&pp, cx)
974                    .map(|buffer| buffer.read(cx).text())
975            })
976        });
977        // Buffer is open (from streaming) but edit 1 is still in-progress
978        assert_eq!(
979            buffer_text.as_deref(),
980            Some("line 1\nline 2\nline 3\nline 4\nline 5\n"),
981            "Buffer should not be modified while first edit is still in progress"
982        );
983
984        // Second edit appears — this proves the first edit is complete, so it
985        // should be applied immediately during streaming
986        sender.send_partial(json!({
987            "path": "root/file.txt",
988            "edits": [
989                {"old_text": "line 1", "new_text": "MODIFIED 1"},
990                {"old_text": "line 5"}
991            ]
992        }));
993        cx.run_until_parked();
994
995        // First edit should now be applied to the buffer
996        let buffer_text = project.update(cx, |project, cx| {
997            let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
998            project_path.and_then(|pp| {
999                project
1000                    .get_open_buffer(&pp, cx)
1001                    .map(|buffer| buffer.read(cx).text())
1002            })
1003        });
1004        assert_eq!(
1005            buffer_text.as_deref(),
1006            Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"),
1007            "First edit should be applied during streaming when second edit appears"
1008        );
1009
1010        // Send final complete input
1011        sender.send_full(json!({
1012            "path": "root/file.txt",
1013            "edits": [
1014                {"old_text": "line 1", "new_text": "MODIFIED 1"},
1015                {"old_text": "line 5", "new_text": "MODIFIED 5"}
1016            ]
1017        }));
1018
1019        let result = task.await;
1020        let EditFileToolOutput::Success {
1021            new_text, old_text, ..
1022        } = result.unwrap()
1023        else {
1024            panic!("expected success");
1025        };
1026        assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n");
1027        assert_eq!(
1028            *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n",
1029            "old_text should reflect the original file content before any edits"
1030        );
1031    }
1032
1033    #[gpui::test]
1034    async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) {
1035        let (edit_tool, project, _action_log, _fs, _thread) =
1036            setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
1037        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
1038        let (event_stream, _receiver) = ToolCallEventStream::test();
1039        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
1040
1041        // Setup: description + path + mode
1042        sender.send_partial(json!({
1043            "path": "root/file.txt",
1044        }));
1045        cx.run_until_parked();
1046
1047        // Edit 1 in progress
1048        sender.send_partial(json!({
1049            "path": "root/file.txt",
1050            "edits": [{"old_text": "aaa", "new_text": "AAA"}]
1051        }));
1052        cx.run_until_parked();
1053
1054        // Edit 2 appears — edit 1 is now complete and should be applied
1055        sender.send_partial(json!({
1056            "path": "root/file.txt",
1057            "edits": [
1058                {"old_text": "aaa", "new_text": "AAA"},
1059                {"old_text": "ccc"}
1060            ]
1061        }));
1062        cx.run_until_parked();
1063        sender.send_partial(json!({
1064            "path": "root/file.txt",
1065            "mode": "edit",
1066            "edits": [
1067                {"old_text": "aaa", "new_text": "AAA"},
1068                {"old_text": "ccc", "new_text": "CCC"}
1069            ]
1070        }));
1071        cx.run_until_parked();
1072
1073        // Verify edit 1 fully applied. Edit 2's new_text is being
1074        // streamed: "CCC" is inserted but the old "ccc" isn't deleted
1075        // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done).
1076        let buffer_text = project.update(cx, |project, cx| {
1077            let pp = project
1078                .find_project_path(&PathBuf::from("root/file.txt"), cx)
1079                .unwrap();
1080            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
1081        });
1082        assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n"));
1083
1084        // Edit 3 appears — edit 2 is now complete and should be applied
1085        sender.send_partial(json!({
1086            "path": "root/file.txt",
1087            "edits": [
1088                {"old_text": "aaa", "new_text": "AAA"},
1089                {"old_text": "ccc", "new_text": "CCC"},
1090                {"old_text": "eee"}
1091            ]
1092        }));
1093        cx.run_until_parked();
1094        sender.send_partial(json!({
1095            "path": "root/file.txt",
1096            "mode": "edit",
1097            "edits": [
1098                {"old_text": "aaa", "new_text": "AAA"},
1099                {"old_text": "ccc", "new_text": "CCC"},
1100                {"old_text": "eee", "new_text": "EEE"}
1101            ]
1102        }));
1103        cx.run_until_parked();
1104
1105        // Verify edits 1 and 2 fully applied. Edit 3's new_text is being
1106        // streamed: "EEE" is inserted but old "eee" isn't deleted yet.
1107        let buffer_text = project.update(cx, |project, cx| {
1108            let pp = project
1109                .find_project_path(&PathBuf::from("root/file.txt"), cx)
1110                .unwrap();
1111            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
1112        });
1113        assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n"));
1114
1115        // Send final
1116        sender.send_full(json!({
1117            "path": "root/file.txt",
1118            "edits": [
1119                {"old_text": "aaa", "new_text": "AAA"},
1120                {"old_text": "ccc", "new_text": "CCC"},
1121                {"old_text": "eee", "new_text": "EEE"}
1122            ]
1123        }));
1124
1125        let result = task.await;
1126        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1127            panic!("expected success");
1128        };
1129        assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n");
1130    }
1131
1132    #[gpui::test]
1133    async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) {
1134        let (edit_tool, project, _action_log, _fs, _thread) =
1135            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1136        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
1137        let (event_stream, _receiver) = ToolCallEventStream::test();
1138        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
1139
1140        // Setup
1141        sender.send_partial(json!({
1142            "path": "root/file.txt",
1143        }));
1144        cx.run_until_parked();
1145
1146        // Edit 1 (valid) in progress — not yet complete (no second edit)
1147        sender.send_partial(json!({
1148            "path": "root/file.txt",
1149            "edits": [
1150                {"old_text": "line 1", "new_text": "MODIFIED"}
1151            ]
1152        }));
1153        cx.run_until_parked();
1154
1155        // Edit 2 appears (will fail to match) — this makes edit 1 complete.
1156        // Edit 1 should be applied. Edit 2 is still in-progress (last edit).
1157        sender.send_partial(json!({
1158            "path": "root/file.txt",
1159            "edits": [
1160                {"old_text": "line 1", "new_text": "MODIFIED"},
1161                {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}
1162            ]
1163        }));
1164        cx.run_until_parked();
1165
1166        let buffer = project.update(cx, |project, cx| {
1167            let pp = project
1168                .find_project_path(&PathBuf::from("root/file.txt"), cx)
1169                .unwrap();
1170            project.get_open_buffer(&pp, cx).unwrap()
1171        });
1172
1173        // Verify edit 1 was applied
1174        let buffer_text = buffer.read_with(cx, |buffer, _cx| buffer.text());
1175        assert_eq!(
1176            buffer_text, "MODIFIED\nline 2\nline 3\n",
1177            "First edit should be applied even though second edit will fail"
1178        );
1179
1180        // Edit 3 appears — this makes edit 2 "complete", triggering its
1181        // resolution which should fail (old_text doesn't exist in the file).
1182        sender.send_partial(json!({
1183            "path": "root/file.txt",
1184            "edits": [
1185                {"old_text": "line 1", "new_text": "MODIFIED"},
1186                {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"},
1187                {"old_text": "line 3", "new_text": "MODIFIED 3"}
1188            ]
1189        }));
1190        cx.run_until_parked();
1191
1192        // The error from edit 2 should have propagated out of the partial loop.
1193        // Drop sender to unblock recv() if the loop didn't catch it.
1194        drop(sender);
1195
1196        let result = task.await;
1197        let EditFileToolOutput::Error {
1198            error,
1199            diff,
1200            input_path,
1201        } = result.unwrap_err()
1202        else {
1203            panic!("expected error");
1204        };
1205
1206        assert!(
1207            error.contains("Could not find matching text for edit at index 1"),
1208            "Expected error about edit 1 failing, got: {error}"
1209        );
1210        // Ensure that first edit was applied successfully and that we saved the buffer
1211        assert_eq!(input_path, Some(PathBuf::from("root/file.txt")));
1212        assert_eq!(
1213            diff,
1214            "@@ -1,3 +1,3 @@\n-line 1\n+MODIFIED\n line 2\n line 3\n"
1215        );
1216    }
1217
1218    #[gpui::test]
1219    async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {
1220        let (edit_tool, project, _action_log, _fs, _thread) =
1221            setup_test(cx, json!({"file.txt": "hello world\n"})).await;
1222        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
1223        let (event_stream, _receiver) = ToolCallEventStream::test();
1224        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
1225
1226        // Setup + single edit that stays in-progress (no second edit to prove completion)
1227        sender.send_partial(json!({
1228            "path": "root/file.txt",
1229        }));
1230        cx.run_until_parked();
1231
1232        sender.send_partial(json!({
1233            "path": "root/file.txt",
1234            "edits": [{"old_text": "hello world"}]
1235        }));
1236        cx.run_until_parked();
1237
1238        sender.send_partial(json!({
1239            "path": "root/file.txt",
1240            "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
1241        }));
1242        cx.run_until_parked();
1243
1244        // The edit's old_text and new_text both arrived in one partial, so
1245        // the old_text is resolved and new_text is being streamed via
1246        // StreamingDiff. The buffer reflects the in-progress diff (new text
1247        // inserted, old text not yet fully removed until finalization).
1248        let buffer_text = project.update(cx, |project, cx| {
1249            let pp = project
1250                .find_project_path(&PathBuf::from("root/file.txt"), cx)
1251                .unwrap();
1252            project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
1253        });
1254        assert_eq!(
1255            buffer_text.as_deref(),
1256            Some("goodbye worldhello world\n"),
1257            "In-progress streaming diff: new text inserted, old text not yet removed"
1258        );
1259
1260        // Send final — the edit is applied during finalization
1261        sender.send_full(json!({
1262            "path": "root/file.txt",
1263            "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
1264        }));
1265
1266        let result = task.await;
1267        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1268            panic!("expected success");
1269        };
1270        assert_eq!(new_text, "goodbye world\n");
1271    }
1272
1273    #[gpui::test]
1274    async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) {
1275        let (edit_tool, _project, _action_log, _fs, _thread) =
1276            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1277        let (mut sender, input): (ToolInputSender, ToolInput<EditFileToolInput>) =
1278            ToolInput::test();
1279        let (event_stream, _event_rx) = ToolCallEventStream::test();
1280        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
1281
1282        // Send progressively more complete partial snapshots, as the LLM would
1283        sender.send_partial(json!({}));
1284        cx.run_until_parked();
1285
1286        sender.send_partial(json!({
1287            "path": "root/file.txt",
1288        }));
1289        cx.run_until_parked();
1290
1291        sender.send_partial(json!({
1292            "path": "root/file.txt",
1293            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
1294        }));
1295        cx.run_until_parked();
1296
1297        // Send the final complete input
1298        sender.send_full(json!({
1299            "path": "root/file.txt",
1300            "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
1301        }));
1302
1303        let result = task.await;
1304        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1305            panic!("expected success");
1306        };
1307        assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1308    }
1309
1310    #[gpui::test]
1311    async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) {
1312        let (edit_tool, _project, _action_log, _fs, _thread) =
1313            setup_test(cx, json!({"file.txt": "hello world\n"})).await;
1314        let (mut sender, input): (ToolInputSender, ToolInput<EditFileToolInput>) =
1315            ToolInput::test();
1316        let (event_stream, _event_rx) = ToolCallEventStream::test();
1317        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
1318
1319        // Send a partial then drop the sender without sending final
1320        sender.send_partial(json!({}));
1321        cx.run_until_parked();
1322
1323        drop(sender);
1324
1325        let result = task.await;
1326        assert!(
1327            result.is_err(),
1328            "Tool should error when sender is dropped without sending final input"
1329        );
1330    }
1331
1332    #[gpui::test]
1333    async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1334        let mode = EditSessionMode::Edit;
1335
1336        let path_with_root = "root/dir/subdir/existing.txt";
1337        let path_without_root = "dir/subdir/existing.txt";
1338        let result = test_resolve_path(&mode, path_with_root, cx);
1339        assert_resolved_path_eq(result.await, rel_path(path_without_root));
1340
1341        let result = test_resolve_path(&mode, path_without_root, cx);
1342        assert_resolved_path_eq(result.await, rel_path(path_without_root));
1343
1344        let result = test_resolve_path(&mode, "root/nonexistent.txt", cx);
1345        assert_eq!(result.await.unwrap_err(), "Can't edit file: path not found");
1346
1347        let result = test_resolve_path(&mode, "root/dir", cx);
1348        assert_eq!(
1349            result.await.unwrap_err(),
1350            "Can't edit file: path is a directory"
1351        );
1352    }
1353
1354    async fn test_resolve_path(
1355        mode: &EditSessionMode,
1356        path: &str,
1357        cx: &mut TestAppContext,
1358    ) -> Result<ProjectPath, String> {
1359        init_test(cx);
1360
1361        let fs = project::FakeFs::new(cx.executor());
1362        fs.insert_tree(
1363            "/root",
1364            json!({
1365                "dir": {
1366                    "subdir": {
1367                        "existing.txt": "hello"
1368                    }
1369                }
1370            }),
1371        )
1372        .await;
1373        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1374
1375        crate::tools::edit_session::test_resolve_path(mode, path, &project, cx).await
1376    }
1377
1378    #[track_caller]
1379    fn assert_resolved_path_eq(path: Result<ProjectPath, String>, expected: &RelPath) {
1380        let actual = path.expect("Should return valid path").path;
1381        assert_eq!(actual.as_ref(), expected);
1382    }
1383
1384    #[gpui::test]
1385    async fn test_streaming_authorize(cx: &mut TestAppContext) {
1386        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await;
1387
1388        // Test 1: Path with .zed component should require confirmation
1389        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1390        let _auth = cx
1391            .update(|cx| edit_tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx));
1392
1393        let event = stream_rx.expect_authorization().await;
1394        assert_eq!(
1395            event.tool_call.fields.title,
1396            Some("Edit `.zed/settings.json` (local settings)".into())
1397        );
1398
1399        // Test 2: Path outside project should require confirmation
1400        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1401        let _auth =
1402            cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx));
1403
1404        let event = stream_rx.expect_authorization().await;
1405        assert_eq!(
1406            event.tool_call.fields.title,
1407            Some("Edit `/etc/hosts`".into())
1408        );
1409
1410        // Test 3: Relative path without .zed should not require confirmation
1411        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1412        cx.update(|cx| edit_tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx))
1413            .await
1414            .unwrap();
1415        assert!(stream_rx.try_recv().is_err());
1416
1417        // Test 4: Path with .zed in the middle should require confirmation
1418        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1419        let _auth = cx.update(|cx| {
1420            edit_tool.authorize(&PathBuf::from("root/.zed/tasks.json"), &stream_tx, cx)
1421        });
1422        let event = stream_rx.expect_authorization().await;
1423        assert_eq!(
1424            event.tool_call.fields.title,
1425            Some("Edit `root/.zed/tasks.json` (local settings)".into())
1426        );
1427
1428        // Test 5: When global default is allow, sensitive and outside-project
1429        // paths still require confirmation
1430        cx.update(|cx| {
1431            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1432            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1433            agent_settings::AgentSettings::override_global(settings, cx);
1434        });
1435
1436        // 5.1: .zed/settings.json is a sensitive path — still prompts
1437        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1438        let _auth = cx
1439            .update(|cx| edit_tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx));
1440        let event = stream_rx.expect_authorization().await;
1441        assert_eq!(
1442            event.tool_call.fields.title,
1443            Some("Edit `.zed/settings.json` (local settings)".into())
1444        );
1445
1446        // 5.2: /etc/hosts is outside the project, but Allow auto-approves
1447        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1448        cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx))
1449            .await
1450            .unwrap();
1451        assert!(stream_rx.try_recv().is_err());
1452
1453        // 5.3: Normal in-project path with allow — no confirmation needed
1454        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1455        cx.update(|cx| edit_tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx))
1456            .await
1457            .unwrap();
1458        assert!(stream_rx.try_recv().is_err());
1459
1460        // 5.4: With Confirm default, non-project paths still prompt
1461        cx.update(|cx| {
1462            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1463            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
1464            agent_settings::AgentSettings::override_global(settings, cx);
1465        });
1466
1467        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1468        let _auth =
1469            cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx));
1470
1471        let event = stream_rx.expect_authorization().await;
1472        assert_eq!(
1473            event.tool_call.fields.title,
1474            Some("Edit `/etc/hosts`".into())
1475        );
1476
1477        // 5.5: .agents/skills is a sensitive path — still prompts. The
1478        // sensitive-path classifier runs regardless of the default mode, so
1479        // it doesn't matter that we're now in Confirm mode — we're checking
1480        // that the path is recognized and gets the "(agent skills)" tag.
1481        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1482        let _auth = cx.update(|cx| {
1483            edit_tool.authorize(
1484                &PathBuf::from("root/.agents/skills/my-skill/SKILL.md"),
1485                &stream_tx,
1486                cx,
1487            )
1488        });
1489        let event = stream_rx.expect_authorization().await;
1490        assert_eq!(
1491            event.tool_call.fields.title,
1492            Some("Edit `root/.agents/skills/my-skill/SKILL.md` (agent skills)".into())
1493        );
1494        // Skills always prompt, so no "Always allow" option is offered.
1495        assert!(
1496            event
1497                .options
1498                .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
1499                .is_none(),
1500            "agent skills prompt must not offer an \"Always allow\" option: {:?}",
1501            event.options,
1502        );
1503        assert!(
1504            matches!(event.options, acp_thread::PermissionOptions::Flat(_)),
1505            "agent skills prompt should use flat allow/deny options: {:?}",
1506            event.options,
1507        );
1508
1509        // 5.6: The global .agents/skills directory is sensitive — still prompts
1510        let global_skill_path = agent_skills::global_skills_dir()
1511            .join("my-skill")
1512            .join("SKILL.md");
1513        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1514        let _auth = cx.update(|cx| edit_tool.authorize(&global_skill_path, &stream_tx, cx));
1515        let event = stream_rx.expect_authorization().await;
1516        assert!(
1517            event
1518                .tool_call
1519                .fields
1520                .title
1521                .as_deref()
1522                .is_some_and(|title| title.ends_with("(agent skills)"))
1523        );
1524    }
1525
1526    /// `.agents/foo/../skills/SKILL.md` would slip past the raw
1527    /// `is_agents_skills_path` check (the components `.agents` and
1528    /// `skills` aren't consecutive once `..` sits between them), but it
1529    /// canonicalizes to a path inside `.agents/skills/`, so it has to
1530    /// still prompt with the agent-skills tag.
1531    #[gpui::test]
1532    async fn test_streaming_authorize_blocks_dotdot_skills_bypass(cx: &mut TestAppContext) {
1533        init_test(cx);
1534        let fs = project::FakeFs::new(cx.executor());
1535        fs.insert_tree(
1536            path!("/root"),
1537            json!({
1538                ".agents": {
1539                    "foo": {},
1540                    "skills": { "my-skill": { "SKILL.md": "target" } },
1541                },
1542            }),
1543        )
1544        .await;
1545        let (edit_tool, _project, _action_log, _fs, _thread) =
1546            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1547
1548        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1549        let _auth = cx.update(|cx| {
1550            edit_tool.authorize(
1551                &PathBuf::from(path!("/root/.agents/foo/../skills/my-skill/SKILL.md")),
1552                &stream_tx,
1553                cx,
1554            )
1555        });
1556        let event = stream_rx.expect_authorization().await;
1557        assert!(
1558            event
1559                .tool_call
1560                .fields
1561                .title
1562                .as_deref()
1563                .is_some_and(|title| title.ends_with("(agent skills)")),
1564            "`..` traversal into .agents/skills must still prompt: {:?}",
1565            event.tool_call.fields.title,
1566        );
1567    }
1568
1569    /// `.zed/foo/../../safe.json` similarly sidesteps the consecutive-
1570    /// component scan for `.zed/`, so the canonical-path recheck has to
1571    /// catch it. (We escape *out* of `.zed/` here and back in via `..`,
1572    /// just to confirm the recheck doesn't naively trust the raw scan.)
1573    #[gpui::test]
1574    async fn test_streaming_authorize_blocks_dotdot_settings_bypass(cx: &mut TestAppContext) {
1575        init_test(cx);
1576        let fs = project::FakeFs::new(cx.executor());
1577        fs.insert_tree(
1578            path!("/root"),
1579            json!({
1580                ".zed": { "foo": {}, "settings.json": "{}" },
1581            }),
1582        )
1583        .await;
1584        let (edit_tool, _project, _action_log, _fs, _thread) =
1585            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1586
1587        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1588        let _auth = cx.update(|cx| {
1589            edit_tool.authorize(
1590                &PathBuf::from(path!("/root/.zed/foo/../settings.json")),
1591                &stream_tx,
1592                cx,
1593            )
1594        });
1595        let event = stream_rx.expect_authorization().await;
1596        assert!(
1597            event
1598                .tool_call
1599                .fields
1600                .title
1601                .as_deref()
1602                .is_some_and(|title| title.ends_with("(local settings)")),
1603            "`..` traversal into .zed must still prompt: {:?}",
1604            event.tool_call.fields.title,
1605        );
1606    }
1607
1608    /// An intra-project symlink like `safe -> .zed` keeps a path's
1609    /// raw components clean of `.zed`, and `resolve_project_path`
1610    /// (correctly) doesn't flag the symlink as an escape because the
1611    /// target stays inside the worktree. The canonical-path recheck is
1612    /// the only thing standing between the agent and a silent settings
1613    /// rewrite, so verify it fires.
1614    #[gpui::test]
1615    async fn test_streaming_authorize_blocks_intra_project_symlink_bypass(cx: &mut TestAppContext) {
1616        init_test(cx);
1617        let fs = project::FakeFs::new(cx.executor());
1618        fs.insert_tree(
1619            path!("/root"),
1620            json!({
1621                ".zed": { "settings.json": "{}" },
1622            }),
1623        )
1624        .await;
1625        fs.insert_symlink(path!("/root/safe"), PathBuf::from(".zed"))
1626            .await;
1627        let (edit_tool, _project, _action_log, _fs, _thread) =
1628            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1629
1630        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1631        let _auth = cx.update(|cx| {
1632            edit_tool.authorize(
1633                &PathBuf::from(path!("/root/safe/settings.json")),
1634                &stream_tx,
1635                cx,
1636            )
1637        });
1638        let event = stream_rx.expect_authorization().await;
1639        assert!(
1640            event
1641                .tool_call
1642                .fields
1643                .title
1644                .as_deref()
1645                .is_some_and(|title| title.ends_with("(local settings)")),
1646            "Intra-project symlink to .zed must still prompt: {:?}",
1647            event.tool_call.fields.title,
1648        );
1649    }
1650
1651    /// Same as the previous test but for the agent-skills sensitive
1652    /// path, via an intra-project symlink `safe -> .agents/skills`.
1653    #[gpui::test]
1654    async fn test_streaming_authorize_blocks_intra_project_symlink_skills_bypass(
1655        cx: &mut TestAppContext,
1656    ) {
1657        init_test(cx);
1658        let fs = project::FakeFs::new(cx.executor());
1659        fs.insert_tree(
1660            path!("/root"),
1661            json!({
1662                ".agents": {
1663                    "skills": { "my-skill": { "SKILL.md": "target" } },
1664                },
1665            }),
1666        )
1667        .await;
1668        fs.insert_symlink(path!("/root/safe"), PathBuf::from(".agents/skills"))
1669            .await;
1670        let (edit_tool, _project, _action_log, _fs, _thread) =
1671            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1672
1673        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1674        let _auth = cx.update(|cx| {
1675            edit_tool.authorize(
1676                &PathBuf::from(path!("/root/safe/my-skill/SKILL.md")),
1677                &stream_tx,
1678                cx,
1679            )
1680        });
1681        let event = stream_rx.expect_authorization().await;
1682        assert!(
1683            event
1684                .tool_call
1685                .fields
1686                .title
1687                .as_deref()
1688                .is_some_and(|title| title.ends_with("(agent skills)")),
1689            "Intra-project symlink to .agents/skills must still prompt: {:?}",
1690            event.tool_call.fields.title,
1691        );
1692    }
1693
1694    #[gpui::test]
1695    async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) {
1696        init_test(cx);
1697
1698        let fs = project::FakeFs::new(cx.executor());
1699        fs.insert_tree("/root", json!({})).await;
1700        fs.insert_tree("/outside", json!({})).await;
1701        fs.insert_symlink("/root/link", PathBuf::from("/outside"))
1702            .await;
1703        let (edit_tool, _project, _action_log, _fs, _thread) =
1704            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1705
1706        cx.update(|cx| {
1707            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1708            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1709            agent_settings::AgentSettings::override_global(settings, cx);
1710        });
1711
1712        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1713        let authorize_task =
1714            cx.update(|cx| edit_tool.authorize(&PathBuf::from("link/new.txt"), &stream_tx, cx));
1715
1716        let event = stream_rx.expect_authorization().await;
1717        assert!(
1718            event
1719                .tool_call
1720                .fields
1721                .title
1722                .as_deref()
1723                .is_some_and(|title| title.contains("points outside the project")),
1724            "Expected symlink escape authorization for create under external symlink"
1725        );
1726
1727        event
1728            .response
1729            .send(acp_thread::SelectedPermissionOutcome::new(
1730                acp::PermissionOptionId::new("allow"),
1731                acp::PermissionOptionKind::AllowOnce,
1732            ))
1733            .unwrap();
1734        authorize_task.await.unwrap();
1735    }
1736
1737    #[gpui::test]
1738    async fn test_streaming_edit_file_symlink_escape_requests_authorization(
1739        cx: &mut TestAppContext,
1740    ) {
1741        init_test(cx);
1742
1743        let fs = project::FakeFs::new(cx.executor());
1744        fs.insert_tree(
1745            path!("/root"),
1746            json!({
1747                "src": { "main.rs": "fn main() {}" }
1748            }),
1749        )
1750        .await;
1751        fs.insert_tree(
1752            path!("/outside"),
1753            json!({
1754                "config.txt": "old content"
1755            }),
1756        )
1757        .await;
1758        fs.create_symlink(
1759            path!("/root/link_to_external").as_ref(),
1760            PathBuf::from("/outside"),
1761        )
1762        .await
1763        .unwrap();
1764        let (edit_tool, _project, _action_log, _fs, _thread) =
1765            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1766
1767        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1768        let _authorize_task = cx.update(|cx| {
1769            edit_tool.authorize(
1770                &PathBuf::from("link_to_external/config.txt"),
1771                &stream_tx,
1772                cx,
1773            )
1774        });
1775
1776        let auth = stream_rx.expect_authorization().await;
1777        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1778        assert!(
1779            title.contains("points outside the project"),
1780            "title should mention symlink escape, got: {title}"
1781        );
1782    }
1783
1784    #[gpui::test]
1785    async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
1786        init_test(cx);
1787
1788        let fs = project::FakeFs::new(cx.executor());
1789        fs.insert_tree(
1790            path!("/root"),
1791            json!({
1792                "src": { "main.rs": "fn main() {}" }
1793            }),
1794        )
1795        .await;
1796        fs.insert_tree(
1797            path!("/outside"),
1798            json!({
1799                "config.txt": "old content"
1800            }),
1801        )
1802        .await;
1803        fs.create_symlink(
1804            path!("/root/link_to_external").as_ref(),
1805            PathBuf::from("/outside"),
1806        )
1807        .await
1808        .unwrap();
1809        let (edit_tool, _project, _action_log, _fs, _thread) =
1810            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1811
1812        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1813        let authorize_task = cx.update(|cx| {
1814            edit_tool.authorize(
1815                &PathBuf::from("link_to_external/config.txt"),
1816                &stream_tx,
1817                cx,
1818            )
1819        });
1820
1821        let auth = stream_rx.expect_authorization().await;
1822        drop(auth); // deny by dropping
1823
1824        let result = authorize_task.await;
1825        assert!(result.is_err(), "should fail when denied");
1826    }
1827
1828    #[gpui::test]
1829    async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
1830        init_test(cx);
1831        cx.update(|cx| {
1832            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1833            settings.tool_permissions.tools.insert(
1834                "edit_file".into(),
1835                agent_settings::ToolRules {
1836                    default: Some(settings::ToolPermissionMode::Deny),
1837                    ..Default::default()
1838                },
1839            );
1840            agent_settings::AgentSettings::override_global(settings, cx);
1841        });
1842
1843        let fs = project::FakeFs::new(cx.executor());
1844        fs.insert_tree(
1845            path!("/root"),
1846            json!({
1847                "src": { "main.rs": "fn main() {}" }
1848            }),
1849        )
1850        .await;
1851        fs.insert_tree(
1852            path!("/outside"),
1853            json!({
1854                "config.txt": "old content"
1855            }),
1856        )
1857        .await;
1858        fs.create_symlink(
1859            path!("/root/link_to_external").as_ref(),
1860            PathBuf::from("/outside"),
1861        )
1862        .await
1863        .unwrap();
1864        let (edit_tool, _project, _action_log, _fs, _thread) =
1865            setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
1866
1867        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1868        let result = cx
1869            .update(|cx| {
1870                edit_tool.authorize(
1871                    &PathBuf::from("link_to_external/config.txt"),
1872                    &stream_tx,
1873                    cx,
1874                )
1875            })
1876            .await;
1877
1878        assert!(result.is_err(), "Tool should fail when policy denies");
1879        assert!(
1880            !matches!(
1881                stream_rx.try_recv(),
1882                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
1883            ),
1884            "Deny policy should not emit symlink authorization prompt",
1885        );
1886    }
1887
1888    #[gpui::test]
1889    async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) {
1890        init_test(cx);
1891        let fs = project::FakeFs::new(cx.executor());
1892        fs.insert_tree("/project", json!({})).await;
1893        let (edit_tool, _project, _action_log, _fs, _thread) =
1894            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
1895
1896        let test_cases = vec![
1897            (
1898                "/etc/hosts",
1899                true,
1900                "System file should require confirmation",
1901            ),
1902            (
1903                "/usr/local/bin/script",
1904                true,
1905                "System bin file should require confirmation",
1906            ),
1907            (
1908                "project/normal_file.rs",
1909                false,
1910                "Normal project file should not require confirmation",
1911            ),
1912        ];
1913
1914        for (path, should_confirm, description) in test_cases {
1915            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1916            let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx));
1917
1918            if should_confirm {
1919                stream_rx.expect_authorization().await;
1920            } else {
1921                auth.await.unwrap();
1922                assert!(
1923                    stream_rx.try_recv().is_err(),
1924                    "Failed for case: {} - path: {} - expected no confirmation but got one",
1925                    description,
1926                    path
1927                );
1928            }
1929        }
1930    }
1931
1932    #[gpui::test]
1933    async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1934        init_test(cx);
1935        let fs = project::FakeFs::new(cx.executor());
1936        fs.insert_tree(
1937            "/workspace/frontend",
1938            json!({
1939                "src": {
1940                    "main.js": "console.log('frontend');"
1941                }
1942            }),
1943        )
1944        .await;
1945        fs.insert_tree(
1946            "/workspace/backend",
1947            json!({
1948                "src": {
1949                    "main.rs": "fn main() {}"
1950                }
1951            }),
1952        )
1953        .await;
1954        fs.insert_tree(
1955            "/workspace/shared",
1956            json!({
1957                ".zed": {
1958                    "settings.json": "{}"
1959                }
1960            }),
1961        )
1962        .await;
1963        let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(
1964            cx,
1965            fs,
1966            &[
1967                path!("/workspace/frontend").as_ref(),
1968                path!("/workspace/backend").as_ref(),
1969                path!("/workspace/shared").as_ref(),
1970            ],
1971        )
1972        .await;
1973
1974        let test_cases = vec![
1975            ("frontend/src/main.js", false, "File in first worktree"),
1976            ("backend/src/main.rs", false, "File in second worktree"),
1977            (
1978                "shared/.zed/settings.json",
1979                true,
1980                ".zed file in third worktree",
1981            ),
1982            ("/etc/hosts", true, "Absolute path outside all worktrees"),
1983            (
1984                "../outside/file.txt",
1985                true,
1986                "Relative path outside worktrees",
1987            ),
1988        ];
1989
1990        for (path, should_confirm, description) in test_cases {
1991            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1992            let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx));
1993
1994            if should_confirm {
1995                stream_rx.expect_authorization().await;
1996            } else {
1997                auth.await.unwrap();
1998                assert!(
1999                    stream_rx.try_recv().is_err(),
2000                    "Failed for case: {} - path: {} - expected no confirmation but got one",
2001                    description,
2002                    path
2003                );
2004            }
2005        }
2006    }
2007
2008    #[gpui::test]
2009    async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2010        init_test(cx);
2011        let fs = project::FakeFs::new(cx.executor());
2012        fs.insert_tree(
2013            "/project",
2014            json!({
2015                ".zed": {
2016                    "settings.json": "{}"
2017                },
2018                "src": {
2019                    ".zed": {
2020                        "local.json": "{}"
2021                    }
2022                }
2023            }),
2024        )
2025        .await;
2026        let (edit_tool, _project, _action_log, _fs, _thread) =
2027            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2028
2029        let test_cases = vec![
2030            ("", false, "Empty path is treated as project root"),
2031            ("/", true, "Root directory should be outside project"),
2032            (
2033                "project/../other",
2034                true,
2035                "Path with .. that goes outside of root directory",
2036            ),
2037            (
2038                "project/./src/file.rs",
2039                false,
2040                "Path with . should work normally",
2041            ),
2042            #[cfg(target_os = "windows")]
2043            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2044            #[cfg(target_os = "windows")]
2045            ("project\\src\\main.rs", false, "Windows-style project path"),
2046        ];
2047
2048        for (path, should_confirm, description) in test_cases {
2049            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2050            let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx));
2051
2052            cx.run_until_parked();
2053
2054            if should_confirm {
2055                stream_rx.expect_authorization().await;
2056            } else {
2057                assert!(
2058                    stream_rx.try_recv().is_err(),
2059                    "Failed for case: {} - path: {} - expected no confirmation but got one",
2060                    description,
2061                    path
2062                );
2063                auth.await.unwrap();
2064            }
2065        }
2066    }
2067
2068    #[gpui::test]
2069    async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2070        init_test(cx);
2071        let fs = project::FakeFs::new(cx.executor());
2072        fs.insert_tree(
2073            "/project",
2074            json!({
2075                "existing.txt": "content",
2076                ".zed": {
2077                    "settings.json": "{}"
2078                }
2079            }),
2080        )
2081        .await;
2082        let (edit_tool, _project, _action_log, _fs, _thread) =
2083            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2084
2085        let modes = vec![EditSessionMode::Edit, EditSessionMode::Write];
2086
2087        for _mode in modes {
2088            // Test .zed path with different modes
2089            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2090            let _auth = cx.update(|cx| {
2091                edit_tool.authorize(&PathBuf::from("project/.zed/settings.json"), &stream_tx, cx)
2092            });
2093
2094            stream_rx.expect_authorization().await;
2095
2096            // Test outside path with different modes
2097            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2098            let _auth = cx.update(|cx| {
2099                edit_tool.authorize(&PathBuf::from("/outside/file.txt"), &stream_tx, cx)
2100            });
2101
2102            stream_rx.expect_authorization().await;
2103
2104            // Test normal path with different modes
2105            let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2106            cx.update(|cx| {
2107                edit_tool.authorize(&PathBuf::from("project/normal.txt"), &stream_tx, cx)
2108            })
2109            .await
2110            .unwrap();
2111            assert!(stream_rx.try_recv().is_err());
2112        }
2113    }
2114
2115    #[gpui::test]
2116    async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) {
2117        init_test(cx);
2118        let fs = project::FakeFs::new(cx.executor());
2119        fs.insert_tree("/project", json!({})).await;
2120        let (edit_tool, _project, _action_log, _fs, _thread) =
2121            setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2122
2123        cx.update(|cx| {
2124            assert_eq!(
2125                edit_tool.initial_title(
2126                    Err(json!({
2127                        "path": "src/main.rs",
2128                    })),
2129                    cx
2130                ),
2131                "src/main.rs"
2132            );
2133            assert_eq!(
2134                edit_tool.initial_title(
2135                    Err(json!({
2136                        "path": "",
2137                    })),
2138                    cx
2139                ),
2140                DEFAULT_UI_TEXT
2141            );
2142            assert_eq!(
2143                edit_tool.initial_title(Err(serde_json::Value::Null), cx),
2144                DEFAULT_UI_TEXT
2145            );
2146        });
2147    }
2148
2149    #[gpui::test]
2150    async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) {
2151        let (edit_tool, project, action_log, _fs, _thread) =
2152            setup_test(cx, json!({"test.txt": "original content"})).await;
2153        let read_tool = Arc::new(crate::ReadFileTool::new(
2154            project.clone(),
2155            action_log.clone(),
2156            true,
2157        ));
2158
2159        // Read the file first
2160        cx.update(|cx| {
2161            read_tool.clone().run(
2162                ToolInput::resolved(crate::ReadFileToolInput {
2163                    path: "root/test.txt".to_string(),
2164                    start_line: None,
2165                    end_line: None,
2166                }),
2167                ToolCallEventStream::test().0,
2168                cx,
2169            )
2170        })
2171        .await
2172        .unwrap();
2173
2174        // First edit should work
2175        let edit_result = cx
2176            .update(|cx| {
2177                edit_tool.clone().run(
2178                    ToolInput::resolved(EditFileToolInput {
2179                        path: "root/test.txt".into(),
2180                        edits: vec![Edit {
2181                            old_text: "original content".into(),
2182                            new_text: "modified content".into(),
2183                        }],
2184                    }),
2185                    ToolCallEventStream::test().0,
2186                    cx,
2187                )
2188            })
2189            .await;
2190        assert!(
2191            edit_result.is_ok(),
2192            "First edit should succeed, got error: {:?}",
2193            edit_result.as_ref().err()
2194        );
2195
2196        // Second edit should also work because the edit updated the recorded read time
2197        let edit_result = cx
2198            .update(|cx| {
2199                edit_tool.clone().run(
2200                    ToolInput::resolved(EditFileToolInput {
2201                        path: "root/test.txt".into(),
2202                        edits: vec![Edit {
2203                            old_text: "modified content".into(),
2204                            new_text: "further modified content".into(),
2205                        }],
2206                    }),
2207                    ToolCallEventStream::test().0,
2208                    cx,
2209                )
2210            })
2211            .await;
2212        assert!(
2213            edit_result.is_ok(),
2214            "Second consecutive edit should succeed, got error: {:?}",
2215            edit_result.as_ref().err()
2216        );
2217    }
2218
2219    #[gpui::test]
2220    async fn test_streaming_external_modification_matching_edit_succeeds(cx: &mut TestAppContext) {
2221        let (edit_tool, project, action_log, fs, _thread) =
2222            setup_test(cx, json!({"test.txt": "original content"})).await;
2223        let read_tool = Arc::new(crate::ReadFileTool::new(
2224            project.clone(),
2225            action_log.clone(),
2226            true,
2227        ));
2228
2229        // Read the file first
2230        cx.update(|cx| {
2231            read_tool.clone().run(
2232                ToolInput::resolved(crate::ReadFileToolInput {
2233                    path: "root/test.txt".to_string(),
2234                    start_line: None,
2235                    end_line: None,
2236                }),
2237                ToolCallEventStream::test().0,
2238                cx,
2239            )
2240        })
2241        .await
2242        .unwrap();
2243
2244        // Simulate external modification
2245        cx.background_executor
2246            .advance_clock(std::time::Duration::from_secs(2));
2247        fs.save(
2248            path!("/root/test.txt").as_ref(),
2249            &"externally modified content".into(),
2250            language::LineEnding::Unix,
2251        )
2252        .await
2253        .unwrap();
2254
2255        // Reload the buffer to pick up the new mtime
2256        let project_path = project
2257            .read_with(cx, |project, cx| {
2258                project.find_project_path("root/test.txt", cx)
2259            })
2260            .expect("Should find project path");
2261        let buffer = project
2262            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2263            .await
2264            .unwrap();
2265        buffer
2266            .update(cx, |buffer, cx| buffer.reload(cx))
2267            .await
2268            .unwrap();
2269
2270        cx.executor().run_until_parked();
2271
2272        let result = cx
2273            .update(|cx| {
2274                edit_tool.clone().run(
2275                    ToolInput::resolved(EditFileToolInput {
2276                        path: "root/test.txt".into(),
2277                        edits: vec![Edit {
2278                            old_text: "externally modified content".into(),
2279                            new_text: "new content".into(),
2280                        }],
2281                    }),
2282                    ToolCallEventStream::test().0,
2283                    cx,
2284                )
2285            })
2286            .await
2287            .unwrap();
2288
2289        let EditFileToolOutput::Success {
2290            new_text,
2291            input_path,
2292            ..
2293        } = result
2294        else {
2295            panic!("expected success");
2296        };
2297
2298        assert_eq!(new_text, "new content");
2299        assert_eq!(input_path, PathBuf::from("root/test.txt"));
2300    }
2301
2302    #[gpui::test]
2303    async fn test_streaming_external_modification_mentioned_when_match_fails(
2304        cx: &mut TestAppContext,
2305    ) {
2306        let (edit_tool, project, action_log, fs, _thread) =
2307            setup_test(cx, json!({"test.txt": "original content"})).await;
2308        let read_tool = Arc::new(crate::ReadFileTool::new(
2309            project.clone(),
2310            action_log.clone(),
2311            true,
2312        ));
2313
2314        cx.update(|cx| {
2315            read_tool.clone().run(
2316                ToolInput::resolved(crate::ReadFileToolInput {
2317                    path: "root/test.txt".to_string(),
2318                    start_line: None,
2319                    end_line: None,
2320                }),
2321                ToolCallEventStream::test().0,
2322                cx,
2323            )
2324        })
2325        .await
2326        .unwrap();
2327
2328        cx.background_executor
2329            .advance_clock(std::time::Duration::from_secs(2));
2330        fs.save(
2331            path!("/root/test.txt").as_ref(),
2332            &"externally modified content".into(),
2333            language::LineEnding::Unix,
2334        )
2335        .await
2336        .unwrap();
2337
2338        let project_path = project
2339            .read_with(cx, |project, cx| {
2340                project.find_project_path("root/test.txt", cx)
2341            })
2342            .expect("Should find project path");
2343        let buffer = project
2344            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2345            .await
2346            .unwrap();
2347        buffer
2348            .update(cx, |buffer, cx| buffer.reload(cx))
2349            .await
2350            .unwrap();
2351
2352        cx.executor().run_until_parked();
2353
2354        let result = cx
2355            .update(|cx| {
2356                edit_tool.clone().run(
2357                    ToolInput::resolved(EditFileToolInput {
2358                        path: "root/test.txt".into(),
2359                        edits: vec![Edit {
2360                            old_text: "original content".into(),
2361                            new_text: "new content".into(),
2362                        }],
2363                    }),
2364                    ToolCallEventStream::test().0,
2365                    cx,
2366                )
2367            })
2368            .await;
2369
2370        let EditFileToolOutput::Error {
2371            error,
2372            diff,
2373            input_path,
2374        } = result.unwrap_err()
2375        else {
2376            panic!("expected error");
2377        };
2378
2379        assert!(
2380            error.contains("Could not find matching text for edit at index 0"),
2381            "Error should mention failed match, got: {error}"
2382        );
2383        assert!(
2384            error.contains("has changed on disk since you last read it"),
2385            "Error should mention possible disk change, got: {error}"
2386        );
2387        assert!(diff.is_empty());
2388        assert_eq!(input_path, Some(PathBuf::from("root/test.txt")));
2389    }
2390
2391    /// When the buffer has unsaved changes and the user picks "Save", the
2392    /// pending edits are flushed to disk and the agent's edit then proceeds
2393    /// against the just-saved content.
2394    #[gpui::test]
2395    async fn test_streaming_dirty_buffer_save(cx: &mut TestAppContext) {
2396        let (edit_tool, project, action_log, fs, _thread) =
2397            setup_test(cx, json!({"test.txt": "original content"})).await;
2398        let read_tool = Arc::new(crate::ReadFileTool::new(
2399            project.clone(),
2400            action_log.clone(),
2401            true,
2402        ));
2403
2404        cx.update(|cx| {
2405            read_tool.clone().run(
2406                ToolInput::resolved(crate::ReadFileToolInput {
2407                    path: "root/test.txt".to_string(),
2408                    start_line: None,
2409                    end_line: None,
2410                }),
2411                ToolCallEventStream::test().0,
2412                cx,
2413            )
2414        })
2415        .await
2416        .unwrap();
2417
2418        let project_path = project
2419            .read_with(cx, |project, cx| {
2420                project.find_project_path("root/test.txt", cx)
2421            })
2422            .expect("Should find project path");
2423        let buffer = project
2424            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2425            .await
2426            .unwrap();
2427
2428        buffer.update(cx, |buffer, cx| {
2429            let end_point = buffer.max_point();
2430            buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
2431        });
2432        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2433
2434        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2435        let task = cx.update(|cx| {
2436            edit_tool.clone().run(
2437                ToolInput::resolved(EditFileToolInput {
2438                    path: "root/test.txt".into(),
2439                    edits: vec![Edit {
2440                        old_text: "original content plus user edit".into(),
2441                        new_text: "replaced content".into(),
2442                    }],
2443                }),
2444                stream_tx,
2445                cx,
2446            )
2447        });
2448
2449        let _update = stream_rx.expect_update_fields().await;
2450        let auth = stream_rx.expect_authorization().await;
2451        let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]);
2452        let acp::ToolCallContent::Content(text) = content.first().expect("expected message body")
2453        else {
2454            panic!("expected text body, got: {:?}", content.first());
2455        };
2456        let acp::ContentBlock::Text(text) = &text.content else {
2457            panic!("expected text body, got: {:?}", text.content);
2458        };
2459        assert!(
2460            text.text.contains("unsaved changes")
2461                && text.text.contains("save")
2462                && text.text.contains("discard"),
2463            "unexpected message body: {:?}",
2464            text.text,
2465        );
2466        auth.response
2467            .send(acp_thread::SelectedPermissionOutcome::new(
2468                acp::PermissionOptionId::new("save"),
2469                acp::PermissionOptionKind::AllowOnce,
2470            ))
2471            .unwrap();
2472
2473        let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
2474            panic!("expected success");
2475        };
2476        assert_eq!(new_text, "replaced content");
2477        assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2478        let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
2479        assert_eq!(on_disk, "replaced content");
2480    }
2481
2482    /// When the buffer has unsaved changes and the user picks "Discard", the
2483    /// pending edits are reverted to match disk and the agent's edit then
2484    /// proceeds against the on-disk content.
2485    #[gpui::test]
2486    async fn test_streaming_dirty_buffer_discard(cx: &mut TestAppContext) {
2487        let (edit_tool, project, action_log, fs, _thread) =
2488            setup_test(cx, json!({"test.txt": "original content"})).await;
2489        let read_tool = Arc::new(crate::ReadFileTool::new(
2490            project.clone(),
2491            action_log.clone(),
2492            true,
2493        ));
2494
2495        cx.update(|cx| {
2496            read_tool.clone().run(
2497                ToolInput::resolved(crate::ReadFileToolInput {
2498                    path: "root/test.txt".to_string(),
2499                    start_line: None,
2500                    end_line: None,
2501                }),
2502                ToolCallEventStream::test().0,
2503                cx,
2504            )
2505        })
2506        .await
2507        .unwrap();
2508
2509        let project_path = project
2510            .read_with(cx, |project, cx| {
2511                project.find_project_path("root/test.txt", cx)
2512            })
2513            .expect("Should find project path");
2514        let buffer = project
2515            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2516            .await
2517            .unwrap();
2518
2519        buffer.update(cx, |buffer, cx| {
2520            let end_point = buffer.max_point();
2521            buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
2522        });
2523        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2524
2525        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2526        let task = cx.update(|cx| {
2527            edit_tool.clone().run(
2528                ToolInput::resolved(EditFileToolInput {
2529                    path: "root/test.txt".into(),
2530                    // Match the on-disk content, not the dirty in-memory content.
2531                    edits: vec![Edit {
2532                        old_text: "original content".into(),
2533                        new_text: "replaced content".into(),
2534                    }],
2535                }),
2536                stream_tx,
2537                cx,
2538            )
2539        });
2540
2541        let _update = stream_rx.expect_update_fields().await;
2542        let auth = stream_rx.expect_authorization().await;
2543        auth.response
2544            .send(acp_thread::SelectedPermissionOutcome::new(
2545                acp::PermissionOptionId::new("discard"),
2546                acp::PermissionOptionKind::RejectOnce,
2547            ))
2548            .unwrap();
2549
2550        let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
2551            panic!("expected success");
2552        };
2553        assert_eq!(new_text, "replaced content");
2554        assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2555        let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
2556        assert_eq!(on_disk, "replaced content");
2557    }
2558
2559    /// When the buffer is dirty and the user resolves it manually — e.g.
2560    /// pressing `cmd-s` while the prompt is visible — the prompt is
2561    /// dismissed automatically and the edit proceeds against the saved
2562    /// content. The user shouldn't have to also click a button.
2563    #[gpui::test]
2564    async fn test_streaming_dirty_buffer_resolved_externally(cx: &mut TestAppContext) {
2565        let (edit_tool, project, action_log, fs, _thread) =
2566            setup_test(cx, json!({"test.txt": "original content"})).await;
2567        let read_tool = Arc::new(crate::ReadFileTool::new(
2568            project.clone(),
2569            action_log.clone(),
2570            true,
2571        ));
2572
2573        cx.update(|cx| {
2574            read_tool.clone().run(
2575                ToolInput::resolved(crate::ReadFileToolInput {
2576                    path: "root/test.txt".to_string(),
2577                    start_line: None,
2578                    end_line: None,
2579                }),
2580                ToolCallEventStream::test().0,
2581                cx,
2582            )
2583        })
2584        .await
2585        .unwrap();
2586
2587        let project_path = project
2588            .read_with(cx, |project, cx| {
2589                project.find_project_path("root/test.txt", cx)
2590            })
2591            .expect("Should find project path");
2592        let buffer = project
2593            .update(cx, |project, cx| project.open_buffer(project_path, cx))
2594            .await
2595            .unwrap();
2596
2597        buffer.update(cx, |buffer, cx| {
2598            let end_point = buffer.max_point();
2599            buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
2600        });
2601        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2602
2603        let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2604        let task = cx.update(|cx| {
2605            edit_tool.clone().run(
2606                ToolInput::resolved(EditFileToolInput {
2607                    path: "root/test.txt".into(),
2608                    edits: vec![Edit {
2609                        old_text: "original content plus user edit".into(),
2610                        new_text: "replaced content".into(),
2611                    }],
2612                }),
2613                stream_tx,
2614                cx,
2615            )
2616        });
2617
2618        let _update = stream_rx.expect_update_fields().await;
2619        let auth = stream_rx.expect_authorization().await;
2620
2621        // Simulate the user saving the buffer manually (e.g. cmd-s) while
2622        // the prompt is visible. The tool should detect the buffer became
2623        // clean and proceed without the user clicking anything.
2624        project
2625            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2626            .await
2627            .unwrap();
2628
2629        // The prompt's response channel should drop without a click; the
2630        // tool dismisses the prompt by resolving the pending authorization.
2631        let (_, outcome) = stream_rx.expect_authorization_resolved().await;
2632        assert_eq!(outcome.option_id, acp::PermissionOptionId::new("save"));
2633        assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
2634        drop(auth);
2635
2636        let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else {
2637            panic!("expected success");
2638        };
2639        assert_eq!(new_text, "replaced content");
2640        assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2641        let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap();
2642        assert_eq!(on_disk, "replaced content");
2643    }
2644
2645    #[gpui::test]
2646    async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) {
2647        // Edit 1's replacement introduces text that contains edit 2's
2648        // old_text as a substring. Because edits resolve sequentially
2649        // against the current buffer, edit 2 finds a unique match in
2650        // the modified buffer and succeeds.
2651        let (edit_tool, _project, _action_log, _fs, _thread) =
2652            setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
2653        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2654        let (event_stream, _receiver) = ToolCallEventStream::test();
2655        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
2656
2657        // Setup: resolve the buffer
2658        sender.send_partial(json!({
2659            "path": "root/file.txt",
2660        }));
2661        cx.run_until_parked();
2662
2663        // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
2664        // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
2665        // Edit 2's old_text "ccc\nddd" matches the first occurrence
2666        // in the modified buffer and replaces it with "ZZZ".
2667        // Edit 3 exists only to mark edit 2 as "complete" during streaming.
2668        sender.send_partial(json!({
2669            "path": "root/file.txt",
2670            "edits": [
2671                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
2672                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
2673                {"old_text": "eee", "new_text": "DUMMY"}
2674            ]
2675        }));
2676        cx.run_until_parked();
2677
2678        // Send the final input with all three edits.
2679        sender.send_full(json!({
2680            "path": "root/file.txt",
2681            "edits": [
2682                {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
2683                {"old_text": "ccc\nddd", "new_text": "ZZZ"},
2684                {"old_text": "eee", "new_text": "DUMMY"}
2685            ]
2686        }));
2687
2688        let result = task.await;
2689        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2690            panic!("expected success");
2691        };
2692        assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
2693    }
2694
2695    #[gpui::test]
2696    async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) {
2697        let (edit_tool, _project, _action_log, _fs, _thread) =
2698            setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await;
2699        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2700        let (event_stream, _receiver) = ToolCallEventStream::test();
2701        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
2702
2703        sender.send_partial(json!({
2704            "path": "root/file.txt",
2705        }));
2706        cx.run_until_parked();
2707
2708        // Simulate JSON fixer producing a literal backslash when the LLM
2709        // stream cuts in the middle of a \n escape sequence.
2710        // The old_text "hello\nworld" would be streamed as:
2711        //   partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\)
2712        //   partial 2: old_text = "hello\nworld" (fixer corrected the escape)
2713        sender.send_partial(json!({
2714            "path": "root/file.txt",
2715            "edits": [{"old_text": "hello\\"}]
2716        }));
2717        cx.run_until_parked();
2718
2719        // Now the fixer corrects it to the real newline.
2720        sender.send_partial(json!({
2721            "path": "root/file.txt",
2722            "edits": [{"old_text": "hello\nworld"}]
2723        }));
2724        cx.run_until_parked();
2725
2726        // Send final.
2727        sender.send_full(json!({
2728            "path": "root/file.txt",
2729            "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}]
2730        }));
2731
2732        let result = task.await;
2733        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2734            panic!("expected success");
2735        };
2736        assert_eq!(new_text, "HELLO\nWORLD\nfoo\n");
2737    }
2738
2739    #[gpui::test]
2740    async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) {
2741        let (edit_tool, _project, _action_log, _fs, _thread) =
2742            setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await;
2743        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2744        let (event_stream, _receiver) = ToolCallEventStream::test();
2745        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
2746
2747        sender.send_partial(json!({
2748            "path": "root/file.txt",
2749        }));
2750        cx.run_until_parked();
2751
2752        sender.send_full(json!({
2753            "path": "root/file.txt",
2754            "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]"
2755        }));
2756
2757        let result = task.await;
2758        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2759            panic!("expected success");
2760        };
2761        assert_eq!(new_text, "HELLO\nWORLD\n");
2762    }
2763
2764    // Verifies that after streaming_edit_file_tool edits a file, the action log
2765    // reports changed buffers so that the Accept All / Reject All review UI appears.
2766    #[gpui::test]
2767    async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) {
2768        let (edit_tool, _project, action_log, _fs, _thread) =
2769            setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
2770        cx.update(|cx| {
2771            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2772            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
2773            agent_settings::AgentSettings::override_global(settings, cx);
2774        });
2775
2776        let (event_stream, _rx) = ToolCallEventStream::test();
2777        let task = cx.update(|cx| {
2778            edit_tool.clone().run(
2779                ToolInput::resolved(EditFileToolInput {
2780                    path: "root/file.txt".into(),
2781                    edits: vec![Edit {
2782                        old_text: "line 2".into(),
2783                        new_text: "modified line 2".into(),
2784                    }],
2785                }),
2786                event_stream,
2787                cx,
2788            )
2789        });
2790
2791        let result = task.await;
2792        assert!(result.is_ok(), "edit should succeed: {:?}", result.err());
2793
2794        cx.run_until_parked();
2795
2796        let changed =
2797            action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::<Vec<_>>());
2798        assert!(
2799            !changed.is_empty(),
2800            "action_log.changed_buffers() should be non-empty after streaming edit,
2801             but no changed buffers were found - Accept All / Reject All will not appear"
2802        );
2803    }
2804
2805    // Same test but for Write mode (overwrite entire file).
2806
2807    #[gpui::test]
2808    async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
2809        cx: &mut TestAppContext,
2810    ) {
2811        let (edit_tool, _project, _action_log, _fs, _thread) =
2812            setup_test(cx, json!({"file.txt": "old_content"})).await;
2813        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2814        let (event_stream, _receiver) = ToolCallEventStream::test();
2815        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
2816
2817        sender.send_partial(json!({
2818            "edits": [{"old_text": "old_content"}]
2819        }));
2820        cx.run_until_parked();
2821
2822        sender.send_partial(json!({
2823            "edits": [{"old_text": "old_content", "new_text": "new_content"}]
2824        }));
2825        cx.run_until_parked();
2826
2827        sender.send_partial(json!({
2828            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
2829            "path": "root"
2830        }));
2831        cx.run_until_parked();
2832
2833        // Send final.
2834        sender.send_full(json!({
2835            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
2836            "path": "root/file.txt"
2837        }));
2838        cx.run_until_parked();
2839
2840        let result = task.await;
2841        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2842            panic!("expected success");
2843        };
2844        assert_eq!(new_text, "new_content");
2845    }
2846
2847    #[gpui::test]
2848    async fn test_streaming_edit_file_tool_new_and_old_text_appear_together(
2849        cx: &mut TestAppContext,
2850    ) {
2851        let (tool, _project, _action_log, _fs, _thread) =
2852            setup_test(cx, json!({"file.txt": "old_content"})).await;
2853        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2854        let (event_stream, _receiver) = ToolCallEventStream::test();
2855        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2856
2857        sender.send_partial(json!({
2858            "mode": "edit",
2859            "path": "root/file.txt"
2860        }));
2861        cx.run_until_parked();
2862
2863        sender.send_partial(json!({
2864            "mode": "edit",
2865            "path": "root/file.txt",
2866            "edits": [{"new_text": "new_content", "old_text": "old"}]
2867        }));
2868        cx.run_until_parked();
2869
2870        sender.send_partial(json!({
2871            "mode": "edit",
2872            "path": "root/file.txt",
2873            "edits": [{"new_text": "new_content", "old_text": "old_content"}]
2874        }));
2875        cx.run_until_parked();
2876
2877        sender.send_full(json!({
2878            "mode": "edit",
2879            "path": "root/file.txt",
2880            "edits": [{"new_text": "new_content", "old_text": "old_content"}]
2881        }));
2882        cx.run_until_parked();
2883
2884        let result = task.await;
2885        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2886            panic!("expected success");
2887        };
2888        assert_eq!(new_text, "new_content");
2889    }
2890
2891    #[gpui::test]
2892    async fn test_streaming_edit_file_tool_new_text_before_old_text(cx: &mut TestAppContext) {
2893        let (tool, _project, _action_log, _fs, _thread) =
2894            setup_test(cx, json!({"file.txt": "old_content"})).await;
2895        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2896        let (event_stream, _receiver) = ToolCallEventStream::test();
2897        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2898
2899        sender.send_partial(json!({
2900            "mode": "edit",
2901            "path": "root/file.txt"
2902        }));
2903        cx.run_until_parked();
2904
2905        sender.send_partial(json!({
2906            "mode": "edit",
2907            "path": "root/file.txt",
2908            "edits": [{"new_text": "new_content"}]
2909        }));
2910        cx.run_until_parked();
2911
2912        sender.send_partial(json!({
2913            "mode": "edit",
2914            "path": "root/file.txt",
2915            "edits": [{"new_text": "new_content", "old_text": ""}]
2916        }));
2917        cx.run_until_parked();
2918
2919        sender.send_partial(json!({
2920            "mode": "edit",
2921            "path": "root/file.txt",
2922            "edits": [{"new_text": "new_content", "old_text": "old"}]
2923        }));
2924        cx.run_until_parked();
2925
2926        sender.send_full(json!({
2927            "mode": "edit",
2928            "path": "root/file.txt",
2929            "edits": [{"new_text": "new_content", "old_text": "old_content"}]
2930        }));
2931        cx.run_until_parked();
2932
2933        let result = task.await;
2934        let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2935            panic!("expected success");
2936        };
2937        assert_eq!(new_text, "new_content");
2938    }
2939
2940    #[gpui::test]
2941    async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) {
2942        let file_content = indoc::indoc! {r#"
2943            fn on_query_change(&mut self, cx: &mut Context<Self>) {
2944                self.filter(cx);
2945            }
2946
2947
2948
2949            fn render_search(&self, cx: &mut Context<Self>) -> Div {
2950                div()
2951            }
2952        "#}
2953        .to_string();
2954
2955        let (edit_tool, _project, _action_log, _fs, _thread) =
2956            setup_test(cx, json!({"file.rs": file_content})).await;
2957
2958        // The model sends old_text with a PARTIAL last line.
2959        let old_text = "}\n\n\n\nfn render_search";
2960        let new_text = "}\n\nfn render_search";
2961
2962        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
2963        let (event_stream, _receiver) = ToolCallEventStream::test();
2964        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
2965
2966        sender.send_full(json!({
2967            "path": "root/file.rs",
2968            "edits": [{"old_text": old_text, "new_text": new_text}]
2969        }));
2970
2971        let result = task.await;
2972        let EditFileToolOutput::Success {
2973            new_text: final_text,
2974            ..
2975        } = result.unwrap()
2976        else {
2977            panic!("expected success");
2978        };
2979
2980        // The edit should reduce 3 blank lines to 1 blank line before
2981        // fn render_search, without duplicating the function signature.
2982        let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search");
2983        pretty_assertions::assert_eq!(
2984            final_text,
2985            expected,
2986            "Edit should only remove blank lines before render_search"
2987        );
2988    }
2989
2990    #[gpui::test]
2991    async fn test_streaming_edit_preserves_blank_line_after_trailing_newline_replacement(
2992        cx: &mut TestAppContext,
2993    ) {
2994        let file_content = "before\ntarget\n\nafter\n";
2995        let old_text = "target\n";
2996        let new_text = "one\ntwo\ntarget\n";
2997        let expected = "before\none\ntwo\ntarget\n\nafter\n";
2998
2999        let (edit_tool, _project, _action_log, _fs, _thread) =
3000            setup_test(cx, json!({"file.rs": file_content})).await;
3001        let (mut sender, input) = ToolInput::<EditFileToolInput>::test();
3002        let (event_stream, _receiver) = ToolCallEventStream::test();
3003        let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx));
3004
3005        sender.send_full(json!({
3006            "path": "root/file.rs",
3007            "edits": [{"old_text": old_text, "new_text": new_text}]
3008        }));
3009
3010        let result = task.await;
3011
3012        let EditFileToolOutput::Success {
3013            new_text: final_text,
3014            ..
3015        } = result.unwrap()
3016        else {
3017            panic!("expected success");
3018        };
3019
3020        pretty_assertions::assert_eq!(
3021            final_text,
3022            expected,
3023            "Edit should preserve a single blank line before test_after"
3024        );
3025    }
3026
3027    #[test]
3028    fn test_input_deserializes_double_encoded_fields() {
3029        let input = serde_json::from_value::<EditFileToolInput>(json!({
3030            "path": "root/file.txt",
3031            "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]"
3032        }))
3033        .expect("input should deserialize");
3034
3035        assert_eq!(input.edits.len(), 1);
3036        assert_eq!(input.edits[0].old_text, "hello\nworld");
3037        assert_eq!(input.edits[0].new_text, "HELLO\nWORLD");
3038
3039        let input = serde_json::from_value::<EditFileToolPartialInput>(json!({
3040            "path": "root/file.txt",
3041            "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]"
3042        }))
3043        .expect("input should deserialize");
3044
3045        let edits = input.edits.expect("edits should deserialize");
3046        assert_eq!(edits.len(), 1);
3047        assert_eq!(edits[0].old_text.as_deref(), Some("hello\nworld"));
3048        assert_eq!(edits[0].new_text.as_deref(), Some("HELLO\nWORLD"));
3049
3050        let input = serde_json::from_value::<EditFileToolPartialInput>(json!({
3051            "path": "root/file.txt"
3052        }))
3053        .expect("input should deserialize");
3054        assert!(input.edits.is_none());
3055
3056        let input = serde_json::from_value::<EditFileToolPartialInput>(json!({
3057            "path": "root/file.txt",
3058            "edits": null
3059        }))
3060        .expect("input should deserialize");
3061        assert!(input.edits.is_none());
3062    }
3063
3064    async fn setup_test_with_fs(
3065        cx: &mut TestAppContext,
3066        fs: Arc<project::FakeFs>,
3067        worktree_paths: &[&std::path::Path],
3068    ) -> (
3069        Arc<EditFileTool>,
3070        Entity<Project>,
3071        Entity<ActionLog>,
3072        Arc<project::FakeFs>,
3073        Entity<Thread>,
3074    ) {
3075        let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await;
3076        let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3077        let context_server_registry =
3078            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3079        let model = Arc::new(FakeLanguageModel::default());
3080        let thread = cx.new(|cx| {
3081            crate::Thread::new(
3082                project.clone(),
3083                cx.new(|_cx| ProjectContext::default()),
3084                context_server_registry,
3085                Templates::new(),
3086                Some(model),
3087                cx,
3088            )
3089        });
3090        let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
3091        let edit_tool = Arc::new(EditFileTool::new(
3092            project.clone(),
3093            thread.downgrade(),
3094            action_log.clone(),
3095            language_registry,
3096        ));
3097        (edit_tool, project, action_log, fs, thread)
3098    }
3099
3100    async fn setup_test(
3101        cx: &mut TestAppContext,
3102        initial_tree: serde_json::Value,
3103    ) -> (
3104        Arc<EditFileTool>,
3105        Entity<Project>,
3106        Entity<ActionLog>,
3107        Arc<project::FakeFs>,
3108        Entity<Thread>,
3109    ) {
3110        init_test(cx);
3111        let fs = project::FakeFs::new(cx.executor());
3112        fs.insert_tree("/root", initial_tree).await;
3113        setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await
3114    }
3115
3116    fn init_test(cx: &mut TestAppContext) {
3117        cx.update(|cx| {
3118            let settings_store = SettingsStore::test(cx);
3119            cx.set_global(settings_store);
3120            SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
3121                store.update_user_settings(cx, |settings| {
3122                    settings
3123                        .project
3124                        .all_languages
3125                        .defaults
3126                        .ensure_final_newline_on_save = Some(false);
3127                });
3128            });
3129        });
3130    }
3131}
3132
Served at tenant.openagents/omega Member data and write actions are omitted.