Skip to repository content3132 lines · 116.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:33:22.586Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
edit_file_tool.rs
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