1use super::edit_file_tool::EditFileTool;
2use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
3use super::save_file_tool::SaveFileTool;
4use super::tool_edit_parser::{ToolEditEvent, ToolEditParser};
5use crate::ToolInputPayload;
6use crate::{
7 AgentTool, Thread, ToolCallEventStream, ToolInput,
8 edit_agent::{
9 reindent::{Reindenter, compute_indent_delta},
10 streaming_fuzzy_matcher::StreamingFuzzyMatcher,
11 },
12};
13use acp_thread::Diff;
14use action_log::ActionLog;
15use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
16use anyhow::Result;
17use collections::HashSet;
18use futures::FutureExt as _;
19use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
20use language::language_settings::{self, FormatOnSave};
21use language::{Buffer, LanguageRegistry};
22use language_model::LanguageModelToolResultContent;
23use project::lsp_store::{FormatTrigger, LspFormatTarget};
24use project::{AgentLocation, Project, ProjectPath};
25use schemars::JsonSchema;
26use serde::{
27 Deserialize, Deserializer, Serialize,
28 de::{DeserializeOwned, Error as _},
29};
30use std::ops::Range;
31use std::path::PathBuf;
32use std::sync::Arc;
33use streaming_diff::{CharOperation, StreamingDiff};
34use text::ToOffset;
35use ui::SharedString;
36use util::rel_path::RelPath;
37use util::{Deferred, ResultExt};
38
39const DEFAULT_UI_TEXT: &str = "Editing file";
40
41/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.
42///
43/// Before using this tool:
44///
45/// 1. Use the `read_file` tool to understand the file's contents and context
46///
47/// 2. Verify the directory path is correct (only applicable when creating new files):
48/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
49#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50pub struct StreamingEditFileToolInput {
51 /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI.
52 ///
53 /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
54 ///
55 /// NEVER mention the file path in this description.
56 ///
57 /// <example>Fix API endpoint URLs</example>
58 /// <example>Update copyright year in `page_footer`</example>
59 ///
60 /// Make sure to include this field before all the others in the input object so that we can display it immediately.
61 pub display_description: String,
62
63 /// The full path of the file to create or modify in the project.
64 ///
65 /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
66 ///
67 /// The following examples assume we have two root directories in the project:
68 /// - /a/b/backend
69 /// - /c/d/frontend
70 ///
71 /// <example>
72 /// `backend/src/main.rs`
73 ///
74 /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
75 /// </example>
76 ///
77 /// <example>
78 /// `frontend/db.js`
79 /// </example>
80 pub path: PathBuf,
81
82 /// The mode of operation on the file. Possible values:
83 /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field.
84 /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.
85 ///
86 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
87 pub mode: StreamingEditFileMode,
88
89 /// The complete content for the new file (required for 'write' mode).
90 /// This field should contain the entire file content.
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub content: Option<String>,
93
94 /// List of edit operations to apply sequentially (required for 'edit' mode).
95 /// Each edit finds `old_text` in the file and replaces it with `new_text`.
96 #[serde(
97 default,
98 skip_serializing_if = "Option::is_none",
99 deserialize_with = "deserialize_optional_vec_or_json_string"
100 )]
101 pub edits: Option<Vec<Edit>>,
102}
103
104#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
105#[serde(rename_all = "snake_case")]
106pub enum StreamingEditFileMode {
107 /// Overwrite the file with new content (replacing any existing content).
108 /// If the file does not exist, it will be created.
109 Write,
110 /// Make granular edits to an existing file
111 Edit,
112}
113
114/// A single edit operation that replaces old text with new text
115/// Properly escape all text fields as valid JSON strings.
116/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings.
117#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
118pub struct Edit {
119 /// The exact text to find in the file. This will be matched using fuzzy matching
120 /// to handle minor differences in whitespace or formatting.
121 ///
122 /// Be minimal with replacements:
123 /// - For unique lines, include only those lines
124 /// - For non-unique lines, include enough context to identify them
125 pub old_text: String,
126 /// The text to replace it with
127 pub new_text: String,
128}
129
130#[derive(Clone, Default, Debug, Deserialize)]
131struct StreamingEditFileToolPartialInput {
132 #[serde(default)]
133 display_description: Option<String>,
134 #[serde(default)]
135 path: Option<String>,
136 #[serde(default)]
137 mode: Option<StreamingEditFileMode>,
138 #[serde(default)]
139 content: Option<String>,
140 #[serde(default, deserialize_with = "deserialize_optional_vec_or_json_string")]
141 edits: Option<Vec<PartialEdit>>,
142}
143
144#[derive(Clone, Default, Debug, Deserialize)]
145pub struct PartialEdit {
146 #[serde(default)]
147 pub old_text: Option<String>,
148 #[serde(default)]
149 pub new_text: Option<String>,
150}
151
152/// Sometimes the model responds with a stringified JSON array of edits (`"[...]"`) instead of a regular array (`[...]`)
153fn deserialize_optional_vec_or_json_string<'de, T, D>(
154 deserializer: D,
155) -> Result<Option<Vec<T>>, D::Error>
156where
157 T: DeserializeOwned,
158 D: Deserializer<'de>,
159{
160 #[derive(Deserialize)]
161 #[serde(untagged)]
162 enum VecOrJsonString<T> {
163 Vec(Vec<T>),
164 String(String),
165 }
166
167 let value = Option::<VecOrJsonString<T>>::deserialize(deserializer)?;
168 match value {
169 None => Ok(None),
170 Some(VecOrJsonString::Vec(items)) => Ok(Some(items)),
171 Some(VecOrJsonString::String(string)) => serde_json::from_str::<Vec<T>>(&string)
172 .map(Some)
173 .map_err(|error| {
174 D::Error::custom(format!("failed to parse stringified edits array: {error}"))
175 }),
176 }
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180#[serde(untagged)]
181pub enum StreamingEditFileToolOutput {
182 Success {
183 #[serde(alias = "original_path")]
184 input_path: PathBuf,
185 new_text: String,
186 old_text: Arc<String>,
187 #[serde(default)]
188 diff: String,
189 },
190 Error {
191 error: String,
192 #[serde(default)]
193 input_path: Option<PathBuf>,
194 #[serde(default)]
195 diff: String,
196 },
197}
198
199impl StreamingEditFileToolOutput {
200 pub fn error(error: impl Into<String>) -> Self {
201 Self::Error {
202 error: error.into(),
203 input_path: None,
204 diff: String::new(),
205 }
206 }
207}
208
209impl std::fmt::Display for StreamingEditFileToolOutput {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 StreamingEditFileToolOutput::Success {
213 diff, input_path, ..
214 } => {
215 if diff.is_empty() {
216 write!(f, "No edits were made.")
217 } else {
218 write!(
219 f,
220 "Edited {}:\n\n```diff\n{diff}\n```",
221 input_path.display()
222 )
223 }
224 }
225 StreamingEditFileToolOutput::Error {
226 error,
227 diff,
228 input_path,
229 } => {
230 write!(f, "{error}\n")?;
231 if let Some(input_path) = input_path
232 && !diff.is_empty()
233 {
234 write!(
235 f,
236 "Edited {}:\n\n```diff\n{diff}\n```",
237 input_path.display()
238 )
239 } else {
240 write!(f, "No edits were made.")
241 }
242 }
243 }
244 }
245}
246
247impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
248 fn from(output: StreamingEditFileToolOutput) -> Self {
249 output.to_string().into()
250 }
251}
252
253pub struct StreamingEditFileTool {
254 project: Entity<Project>,
255 thread: WeakEntity<Thread>,
256 action_log: Entity<ActionLog>,
257 language_registry: Arc<LanguageRegistry>,
258}
259
260enum EditSessionResult {
261 Completed(EditSession),
262 Failed {
263 error: String,
264 session: Option<EditSession>,
265 },
266}
267
268impl StreamingEditFileTool {
269 pub fn new(
270 project: Entity<Project>,
271 thread: WeakEntity<Thread>,
272 action_log: Entity<ActionLog>,
273 language_registry: Arc<LanguageRegistry>,
274 ) -> Self {
275 Self {
276 project,
277 thread,
278 action_log,
279 language_registry,
280 }
281 }
282
283 fn authorize(
284 &self,
285 path: &PathBuf,
286 description: &str,
287 event_stream: &ToolCallEventStream,
288 cx: &mut App,
289 ) -> Task<Result<()>> {
290 super::tool_permissions::authorize_file_edit(
291 EditFileTool::NAME,
292 path,
293 description,
294 &self.thread,
295 event_stream,
296 cx,
297 )
298 }
299
300 fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
301 let should_update_agent_location = self
302 .thread
303 .read_with(cx, |thread, _cx| !thread.is_subagent())
304 .unwrap_or_default();
305 if should_update_agent_location {
306 self.project.update(cx, |project, cx| {
307 project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
308 });
309 }
310 }
311
312 async fn ensure_buffer_saved(&self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
313 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
314 let settings = language_settings::LanguageSettings::for_buffer(buffer, cx);
315 settings.format_on_save != FormatOnSave::Off
316 });
317
318 if format_on_save_enabled {
319 self.project
320 .update(cx, |project, cx| {
321 project.format(
322 HashSet::from_iter([buffer.clone()]),
323 LspFormatTarget::Buffers,
324 false,
325 FormatTrigger::Save,
326 cx,
327 )
328 })
329 .await
330 .log_err();
331 }
332
333 self.project
334 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
335 .await
336 .log_err();
337
338 self.action_log.update(cx, |log, cx| {
339 log.buffer_edited(buffer.clone(), cx);
340 });
341 }
342
343 async fn process_streaming_edits(
344 &self,
345 input: &mut ToolInput<StreamingEditFileToolInput>,
346 event_stream: &ToolCallEventStream,
347 cx: &mut AsyncApp,
348 ) -> EditSessionResult {
349 let mut session: Option<EditSession> = None;
350 let mut last_partial: Option<StreamingEditFileToolPartialInput> = None;
351
352 loop {
353 futures::select! {
354 payload = input.next().fuse() => {
355 match payload {
356 Ok(payload) => match payload {
357 ToolInputPayload::Partial(partial) => {
358 if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial) {
359 let path_complete = parsed.path.is_some()
360 && parsed.path.as_ref() == last_partial.as_ref().and_then(|partial| partial.path.as_ref());
361
362 last_partial = Some(parsed.clone());
363
364 if session.is_none()
365 && path_complete
366 && let StreamingEditFileToolPartialInput {
367 path: Some(path),
368 display_description: Some(display_description),
369 mode: Some(mode),
370 ..
371 } = &parsed
372 {
373 match EditSession::new(
374 PathBuf::from(path),
375 display_description,
376 *mode,
377 self,
378 event_stream,
379 cx,
380 )
381 .await
382 {
383 Ok(created_session) => session = Some(created_session),
384 Err(error) => {
385 log::error!("Failed to create edit session: {}", error);
386 return EditSessionResult::Failed {
387 error,
388 session: None,
389 };
390 }
391 }
392 }
393
394 if let Some(current_session) = &mut session
395 && let Err(error) = current_session.process(parsed, self, event_stream, cx)
396 {
397 log::error!("Failed to process edit: {}", error);
398 return EditSessionResult::Failed { error, session };
399 }
400 }
401 }
402 ToolInputPayload::Full(full_input) => {
403 let mut session = if let Some(session) = session {
404 session
405 } else {
406 match EditSession::new(
407 full_input.path.clone(),
408 &full_input.display_description,
409 full_input.mode,
410 self,
411 event_stream,
412 cx,
413 )
414 .await
415 {
416 Ok(created_session) => created_session,
417 Err(error) => {
418 log::error!("Failed to create edit session: {}", error);
419 return EditSessionResult::Failed {
420 error,
421 session: None,
422 };
423 }
424 }
425 };
426
427 return match session.finalize(full_input, self, event_stream, cx).await {
428 Ok(()) => EditSessionResult::Completed(session),
429 Err(error) => {
430 log::error!("Failed to finalize edit: {}", error);
431 EditSessionResult::Failed {
432 error,
433 session: Some(session),
434 }
435 }
436 };
437 }
438 ToolInputPayload::InvalidJson { error_message } => {
439 log::error!("Received invalid JSON: {error_message}");
440 return EditSessionResult::Failed {
441 error: error_message,
442 session,
443 };
444 }
445 },
446 Err(error) => {
447 return EditSessionResult::Failed {
448 error: format!("Failed to receive tool input: {error}"),
449 session,
450 };
451 }
452 }
453 }
454 _ = event_stream.cancelled_by_user().fuse() => {
455 return EditSessionResult::Failed {
456 error: "Edit cancelled by user".to_string(),
457 session,
458 };
459 }
460 }
461 }
462 }
463}
464
465impl AgentTool for StreamingEditFileTool {
466 type Input = StreamingEditFileToolInput;
467 type Output = StreamingEditFileToolOutput;
468
469 const NAME: &'static str = "streaming_edit_file";
470
471 fn supports_input_streaming() -> bool {
472 true
473 }
474
475 fn kind() -> acp::ToolKind {
476 acp::ToolKind::Edit
477 }
478
479 fn initial_title(
480 &self,
481 input: Result<Self::Input, serde_json::Value>,
482 cx: &mut App,
483 ) -> SharedString {
484 match input {
485 Ok(input) => self
486 .project
487 .read(cx)
488 .find_project_path(&input.path, cx)
489 .and_then(|project_path| {
490 self.project
491 .read(cx)
492 .short_full_path_for_project_path(&project_path, cx)
493 })
494 .unwrap_or(input.path.to_string_lossy().into_owned())
495 .into(),
496 Err(raw_input) => {
497 if let Ok(input) =
498 serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input)
499 {
500 let path = input.path.unwrap_or_default();
501 let path = path.trim();
502 if !path.is_empty() {
503 return self
504 .project
505 .read(cx)
506 .find_project_path(&path, cx)
507 .and_then(|project_path| {
508 self.project
509 .read(cx)
510 .short_full_path_for_project_path(&project_path, cx)
511 })
512 .unwrap_or_else(|| path.to_string())
513 .into();
514 }
515
516 let description = input.display_description.unwrap_or_default();
517 let description = description.trim();
518 if !description.is_empty() {
519 return description.to_string().into();
520 }
521 }
522
523 DEFAULT_UI_TEXT.into()
524 }
525 }
526 }
527
528 fn run(
529 self: Arc<Self>,
530 mut input: ToolInput<Self::Input>,
531 event_stream: ToolCallEventStream,
532 cx: &mut App,
533 ) -> Task<Result<Self::Output, Self::Output>> {
534 cx.spawn(async move |cx: &mut AsyncApp| {
535 match self
536 .process_streaming_edits(&mut input, &event_stream, cx)
537 .await
538 {
539 EditSessionResult::Completed(session) => {
540 self.ensure_buffer_saved(&session.buffer, cx).await;
541 let (new_text, diff) = session.compute_new_text_and_diff(cx).await;
542 Ok(StreamingEditFileToolOutput::Success {
543 old_text: session.old_text.clone(),
544 new_text,
545 input_path: session.input_path,
546 diff,
547 })
548 }
549 EditSessionResult::Failed {
550 error,
551 session: Some(session),
552 } => {
553 self.ensure_buffer_saved(&session.buffer, cx).await;
554 let (_new_text, diff) = session.compute_new_text_and_diff(cx).await;
555 Err(StreamingEditFileToolOutput::Error {
556 error,
557 input_path: Some(session.input_path),
558 diff,
559 })
560 }
561 EditSessionResult::Failed {
562 error,
563 session: None,
564 } => Err(StreamingEditFileToolOutput::Error {
565 error,
566 input_path: None,
567 diff: String::new(),
568 }),
569 }
570 })
571 }
572
573 fn replay(
574 &self,
575 _input: Self::Input,
576 output: Self::Output,
577 event_stream: ToolCallEventStream,
578 cx: &mut App,
579 ) -> Result<()> {
580 match output {
581 StreamingEditFileToolOutput::Success {
582 input_path,
583 old_text,
584 new_text,
585 ..
586 } => {
587 event_stream.update_diff(cx.new(|cx| {
588 Diff::finalized(
589 input_path.to_string_lossy().into_owned(),
590 Some(old_text.to_string()),
591 new_text,
592 self.language_registry.clone(),
593 cx,
594 )
595 }));
596 Ok(())
597 }
598 StreamingEditFileToolOutput::Error { .. } => Ok(()),
599 }
600 }
601}
602
603pub struct EditSession {
604 abs_path: PathBuf,
605 input_path: PathBuf,
606 buffer: Entity<Buffer>,
607 old_text: Arc<String>,
608 diff: Entity<Diff>,
609 mode: StreamingEditFileMode,
610 parser: ToolEditParser,
611 pipeline: EditPipeline,
612 _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
613}
614
615struct EditPipeline {
616 current_edit: Option<EditPipelineEntry>,
617 content_written: bool,
618}
619
620enum EditPipelineEntry {
621 ResolvingOldText {
622 matcher: StreamingFuzzyMatcher,
623 },
624 StreamingNewText {
625 streaming_diff: StreamingDiff,
626 edit_cursor: usize,
627 reindenter: Reindenter,
628 original_snapshot: text::BufferSnapshot,
629 },
630}
631
632impl EditPipeline {
633 fn new() -> Self {
634 Self {
635 current_edit: None,
636 content_written: false,
637 }
638 }
639
640 fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
641 if self.current_edit.is_none() {
642 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
643 self.current_edit = Some(EditPipelineEntry::ResolvingOldText {
644 matcher: StreamingFuzzyMatcher::new(snapshot),
645 });
646 }
647 }
648}
649
650impl EditSession {
651 async fn new(
652 path: PathBuf,
653 display_description: &str,
654 mode: StreamingEditFileMode,
655 tool: &StreamingEditFileTool,
656 event_stream: &ToolCallEventStream,
657 cx: &mut AsyncApp,
658 ) -> Result<Self, String> {
659 let project_path = cx.update(|cx| resolve_path(mode, &path, &tool.project, cx))?;
660
661 let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
662 else {
663 return Err(format!(
664 "Worktree at '{}' does not exist",
665 path.to_string_lossy()
666 ));
667 };
668
669 event_stream.update_fields(
670 ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
671 );
672
673 cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
674 .await
675 .map_err(|e| e.to_string())?;
676
677 let buffer = tool
678 .project
679 .update(cx, |project, cx| project.open_buffer(project_path, cx))
680 .await
681 .map_err(|e| e.to_string())?;
682
683 ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
684
685 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
686 event_stream.update_diff(diff.clone());
687 let finalize_diff_guard = util::defer(Box::new({
688 let diff = diff.downgrade();
689 let mut cx = cx.clone();
690 move || {
691 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
692 }
693 }) as Box<dyn FnOnce()>);
694
695 tool.action_log.update(cx, |log, cx| match mode {
696 StreamingEditFileMode::Write => log.buffer_created(buffer.clone(), cx),
697 StreamingEditFileMode::Edit => log.buffer_read(buffer.clone(), cx),
698 });
699
700 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
701 let old_text = cx
702 .background_spawn({
703 let old_snapshot = old_snapshot.clone();
704 async move { Arc::new(old_snapshot.text()) }
705 })
706 .await;
707
708 Ok(Self {
709 abs_path,
710 input_path: path,
711 buffer,
712 old_text,
713 diff,
714 mode,
715 parser: ToolEditParser::default(),
716 pipeline: EditPipeline::new(),
717 _finalize_diff_guard: finalize_diff_guard,
718 })
719 }
720
721 async fn finalize(
722 &mut self,
723 input: StreamingEditFileToolInput,
724 tool: &StreamingEditFileTool,
725 event_stream: &ToolCallEventStream,
726 cx: &mut AsyncApp,
727 ) -> Result<(), String> {
728 match input.mode {
729 StreamingEditFileMode::Write => {
730 let content = input
731 .content
732 .ok_or_else(|| "'content' field is required for write mode".to_string())?;
733
734 let events = self.parser.finalize_content(&content);
735 self.process_events(&events, tool, event_stream, cx)?;
736 }
737 StreamingEditFileMode::Edit => {
738 let edits = input
739 .edits
740 .ok_or_else(|| "'edits' field is required for edit mode".to_string())?;
741 let events = self.parser.finalize_edits(&edits);
742 self.process_events(&events, tool, event_stream, cx)?;
743
744 if log::log_enabled!(log::Level::Debug) {
745 log::debug!("Got edits:");
746 for edit in &edits {
747 log::debug!(
748 " old_text: '{}', new_text: '{}'",
749 edit.old_text.replace('\n', "\\n"),
750 edit.new_text.replace('\n', "\\n")
751 );
752 }
753 }
754 }
755 }
756 Ok(())
757 }
758
759 async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) {
760 let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
761 let (new_text, unified_diff) = cx
762 .background_spawn({
763 let new_snapshot = new_snapshot.clone();
764 let old_text = self.old_text.clone();
765 async move {
766 let new_text = new_snapshot.text();
767 let diff = language::unified_diff(&old_text, &new_text);
768 (new_text, diff)
769 }
770 })
771 .await;
772 (new_text, unified_diff)
773 }
774
775 fn process(
776 &mut self,
777 partial: StreamingEditFileToolPartialInput,
778 tool: &StreamingEditFileTool,
779 event_stream: &ToolCallEventStream,
780 cx: &mut AsyncApp,
781 ) -> Result<(), String> {
782 match &self.mode {
783 StreamingEditFileMode::Write => {
784 if let Some(content) = &partial.content {
785 let events = self.parser.push_content(content);
786 self.process_events(&events, tool, event_stream, cx)?;
787 }
788 }
789 StreamingEditFileMode::Edit => {
790 if let Some(edits) = partial.edits {
791 let events = self.parser.push_edits(&edits);
792 self.process_events(&events, tool, event_stream, cx)?;
793 }
794 }
795 }
796 Ok(())
797 }
798
799 fn process_events(
800 &mut self,
801 events: &[ToolEditEvent],
802 tool: &StreamingEditFileTool,
803 event_stream: &ToolCallEventStream,
804 cx: &mut AsyncApp,
805 ) -> Result<(), String> {
806 for event in events {
807 match event {
808 ToolEditEvent::ContentChunk { chunk } => {
809 let (buffer_id, buffer_len) = self
810 .buffer
811 .read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len()));
812 let edit_range = if self.pipeline.content_written {
813 buffer_len..buffer_len
814 } else {
815 0..buffer_len
816 };
817
818 agent_edit_buffer(
819 &self.buffer,
820 [(edit_range, chunk.as_str())],
821 &tool.action_log,
822 cx,
823 );
824 cx.update(|cx| {
825 tool.set_agent_location(
826 self.buffer.downgrade(),
827 text::Anchor::max_for_buffer(buffer_id),
828 cx,
829 );
830 });
831 self.pipeline.content_written = true;
832 }
833
834 ToolEditEvent::OldTextChunk {
835 chunk, done: false, ..
836 } => {
837 log::debug!("old_text_chunk: done=false, chunk='{}'", chunk);
838 self.pipeline.ensure_resolving_old_text(&self.buffer, cx);
839
840 if let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
841 &mut self.pipeline.current_edit
842 && !chunk.is_empty()
843 {
844 if let Some(match_range) = matcher.push(chunk, None) {
845 let anchor_range = self.buffer.read_with(cx, |buffer, _cx| {
846 buffer.anchor_range_outside(match_range.clone())
847 });
848 self.diff
849 .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
850
851 cx.update(|cx| {
852 let position = self.buffer.read(cx).anchor_before(match_range.end);
853 tool.set_agent_location(self.buffer.downgrade(), position, cx);
854 });
855 }
856 }
857 }
858
859 ToolEditEvent::OldTextChunk {
860 edit_index,
861 chunk,
862 done: true,
863 } => {
864 log::debug!("old_text_chunk: done=true, chunk='{}'", chunk);
865
866 self.pipeline.ensure_resolving_old_text(&self.buffer, cx);
867
868 let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
869 &mut self.pipeline.current_edit
870 else {
871 continue;
872 };
873
874 if !chunk.is_empty() {
875 matcher.push(chunk, None);
876 }
877 let range = extract_match(matcher.finish(), &self.buffer, edit_index, cx)?;
878
879 let anchor_range = self
880 .buffer
881 .read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone()));
882 self.diff
883 .update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
884
885 let snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
886
887 let line = snapshot.offset_to_point(range.start).row;
888 event_stream.update_fields(
889 ToolCallUpdateFields::new().locations(vec![
890 ToolCallLocation::new(&self.abs_path).line(Some(line)),
891 ]),
892 );
893
894 let buffer_indent = snapshot.line_indent_for_row(line);
895 let query_indent = text::LineIndent::from_iter(
896 matcher
897 .query_lines()
898 .first()
899 .map(|s| s.as_str())
900 .unwrap_or("")
901 .chars(),
902 );
903 let indent_delta = compute_indent_delta(buffer_indent, query_indent);
904
905 let old_text_in_buffer =
906 snapshot.text_for_range(range.clone()).collect::<String>();
907
908 log::debug!(
909 "edit[{}] old_text matched at {}..{}: {:?}",
910 edit_index,
911 range.start,
912 range.end,
913 old_text_in_buffer,
914 );
915
916 let text_snapshot = self
917 .buffer
918 .read_with(cx, |buffer, _cx| buffer.text_snapshot());
919 self.pipeline.current_edit = Some(EditPipelineEntry::StreamingNewText {
920 streaming_diff: StreamingDiff::new(old_text_in_buffer),
921 edit_cursor: range.start,
922 reindenter: Reindenter::new(indent_delta),
923 original_snapshot: text_snapshot,
924 });
925
926 cx.update(|cx| {
927 let position = self.buffer.read(cx).anchor_before(range.end);
928 tool.set_agent_location(self.buffer.downgrade(), position, cx);
929 });
930 }
931
932 ToolEditEvent::NewTextChunk {
933 chunk, done: false, ..
934 } => {
935 log::debug!("new_text_chunk: done=false, chunk='{}'", chunk);
936
937 let Some(EditPipelineEntry::StreamingNewText {
938 streaming_diff,
939 edit_cursor,
940 reindenter,
941 original_snapshot,
942 ..
943 }) = &mut self.pipeline.current_edit
944 else {
945 continue;
946 };
947
948 let reindented = reindenter.push(chunk);
949 if reindented.is_empty() {
950 continue;
951 }
952
953 let char_ops = streaming_diff.push_new(&reindented);
954 apply_char_operations(
955 &char_ops,
956 &self.buffer,
957 original_snapshot,
958 edit_cursor,
959 &tool.action_log,
960 cx,
961 );
962
963 let position = original_snapshot.anchor_before(*edit_cursor);
964 cx.update(|cx| {
965 tool.set_agent_location(self.buffer.downgrade(), position, cx);
966 });
967 }
968
969 ToolEditEvent::NewTextChunk {
970 chunk, done: true, ..
971 } => {
972 log::debug!("new_text_chunk: done=true, chunk='{}'", chunk);
973
974 let Some(EditPipelineEntry::StreamingNewText {
975 mut streaming_diff,
976 mut edit_cursor,
977 mut reindenter,
978 original_snapshot,
979 }) = self.pipeline.current_edit.take()
980 else {
981 continue;
982 };
983
984 // Flush any remaining reindent buffer + final chunk.
985 let mut final_text = reindenter.push(chunk);
986 final_text.push_str(&reindenter.finish());
987
988 log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
989
990 if !final_text.is_empty() {
991 let char_ops = streaming_diff.push_new(&final_text);
992 apply_char_operations(
993 &char_ops,
994 &self.buffer,
995 &original_snapshot,
996 &mut edit_cursor,
997 &tool.action_log,
998 cx,
999 );
1000 }
1001
1002 let remaining_ops = streaming_diff.finish();
1003 apply_char_operations(
1004 &remaining_ops,
1005 &self.buffer,
1006 &original_snapshot,
1007 &mut edit_cursor,
1008 &tool.action_log,
1009 cx,
1010 );
1011
1012 let position = original_snapshot.anchor_before(edit_cursor);
1013 cx.update(|cx| {
1014 tool.set_agent_location(self.buffer.downgrade(), position, cx);
1015 });
1016 }
1017 }
1018 }
1019 Ok(())
1020 }
1021}
1022
1023fn apply_char_operations(
1024 ops: &[CharOperation],
1025 buffer: &Entity<Buffer>,
1026 snapshot: &text::BufferSnapshot,
1027 edit_cursor: &mut usize,
1028 action_log: &Entity<ActionLog>,
1029 cx: &mut AsyncApp,
1030) {
1031 for op in ops {
1032 match op {
1033 CharOperation::Insert { text } => {
1034 let anchor = snapshot.anchor_after(*edit_cursor);
1035 agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx);
1036 }
1037 CharOperation::Delete { bytes } => {
1038 let delete_end = *edit_cursor + bytes;
1039 let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end);
1040 agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx);
1041 *edit_cursor = delete_end;
1042 }
1043 CharOperation::Keep { bytes } => {
1044 *edit_cursor += bytes;
1045 }
1046 }
1047 }
1048}
1049
1050fn extract_match(
1051 matches: Vec<Range<usize>>,
1052 buffer: &Entity<Buffer>,
1053 edit_index: &usize,
1054 cx: &mut AsyncApp,
1055) -> Result<Range<usize>, String> {
1056 match matches.len() {
1057 0 => Err(format!(
1058 "Could not find matching text for edit at index {}. \
1059 The old_text did not match any content in the file. \
1060 Please read the file again to get the current content.",
1061 edit_index,
1062 )),
1063 1 => Ok(matches.into_iter().next().unwrap()),
1064 _ => {
1065 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
1066 let lines = matches
1067 .iter()
1068 .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
1069 .collect::<Vec<_>>()
1070 .join(", ");
1071 Err(format!(
1072 "Edit {} matched multiple locations in the file at lines: {}. \
1073 Please provide more context in old_text to uniquely \
1074 identify the location.",
1075 edit_index, lines
1076 ))
1077 }
1078 }
1079}
1080
1081/// Edits a buffer and reports the edit to the action log in the same effect
1082/// cycle. This ensures the action log's subscription handler sees the version
1083/// already updated by `buffer_edited`, so it does not misattribute the agent's
1084/// edit as a user edit.
1085fn agent_edit_buffer<I, S, T>(
1086 buffer: &Entity<Buffer>,
1087 edits: I,
1088 action_log: &Entity<ActionLog>,
1089 cx: &mut AsyncApp,
1090) where
1091 I: IntoIterator<Item = (Range<S>, T)>,
1092 S: ToOffset,
1093 T: Into<Arc<str>>,
1094{
1095 cx.update(|cx| {
1096 buffer.update(cx, |buffer, cx| {
1097 buffer.edit(edits, None, cx);
1098 });
1099 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1100 });
1101}
1102
1103fn ensure_buffer_saved(
1104 buffer: &Entity<Buffer>,
1105 abs_path: &PathBuf,
1106 tool: &StreamingEditFileTool,
1107 cx: &mut AsyncApp,
1108) -> Result<(), String> {
1109 let last_read_mtime = tool
1110 .action_log
1111 .read_with(cx, |log, _| log.file_read_time(abs_path));
1112 let check_result = tool.thread.read_with(cx, |thread, cx| {
1113 let current = buffer
1114 .read(cx)
1115 .file()
1116 .and_then(|file| file.disk_state().mtime());
1117 let dirty = buffer.read(cx).is_dirty();
1118 let has_save = thread.has_tool(SaveFileTool::NAME);
1119 let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
1120 (current, dirty, has_save, has_restore)
1121 });
1122
1123 let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else {
1124 return Ok(());
1125 };
1126
1127 if is_dirty {
1128 let message = match (has_save_tool, has_restore_tool) {
1129 (true, true) => {
1130 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1131 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
1132 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
1133 }
1134 (true, false) => {
1135 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1136 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
1137 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
1138 }
1139 (false, true) => {
1140 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1141 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
1142 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
1143 }
1144 (false, false) => {
1145 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
1146 then ask them to save or revert the file manually and inform you when it's ok to proceed."
1147 }
1148 };
1149 return Err(message.to_string());
1150 }
1151
1152 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
1153 if current != last_read {
1154 return Err("The file has been modified since you last read it. \
1155 Please read the file again to get the current state before editing it."
1156 .to_string());
1157 }
1158 }
1159
1160 Ok(())
1161}
1162
1163fn resolve_path(
1164 mode: StreamingEditFileMode,
1165 path: &PathBuf,
1166 project: &Entity<Project>,
1167 cx: &mut App,
1168) -> Result<ProjectPath, String> {
1169 let project = project.read(cx);
1170
1171 match mode {
1172 StreamingEditFileMode::Edit => {
1173 let path = project
1174 .find_project_path(&path, cx)
1175 .ok_or_else(|| "Can't edit file: path not found".to_string())?;
1176
1177 let entry = project
1178 .entry_for_path(&path, cx)
1179 .ok_or_else(|| "Can't edit file: path not found".to_string())?;
1180
1181 if entry.is_file() {
1182 Ok(path)
1183 } else {
1184 Err("Can't edit file: path is a directory".to_string())
1185 }
1186 }
1187 StreamingEditFileMode::Write => {
1188 if let Some(path) = project.find_project_path(&path, cx)
1189 && let Some(entry) = project.entry_for_path(&path, cx)
1190 {
1191 if entry.is_file() {
1192 return Ok(path);
1193 } else {
1194 return Err("Can't write to file: path is a directory".to_string());
1195 }
1196 }
1197
1198 let parent_path = path
1199 .parent()
1200 .ok_or_else(|| "Can't create file: incorrect path".to_string())?;
1201
1202 let parent_project_path = project.find_project_path(&parent_path, cx);
1203
1204 let parent_entry = parent_project_path
1205 .as_ref()
1206 .and_then(|path| project.entry_for_path(path, cx))
1207 .ok_or_else(|| "Can't create file: parent directory doesn't exist")?;
1208
1209 if !parent_entry.is_dir() {
1210 return Err("Can't create file: parent is not a directory".to_string());
1211 }
1212
1213 let file_name = path
1214 .file_name()
1215 .and_then(|file_name| file_name.to_str())
1216 .and_then(|file_name| RelPath::unix(file_name).ok())
1217 .ok_or_else(|| "Can't create file: invalid filename".to_string())?;
1218
1219 let new_file_path = parent_project_path.map(|parent| ProjectPath {
1220 path: parent.path.join(file_name),
1221 ..parent
1222 });
1223
1224 new_file_path.ok_or_else(|| "Can't create file".to_string())
1225 }
1226 }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use crate::{ContextServerRegistry, Templates, ToolInputSender};
1233 use fs::Fs as _;
1234 use futures::StreamExt as _;
1235 use gpui::{TestAppContext, UpdateGlobal};
1236 use language_model::fake_provider::FakeLanguageModel;
1237 use prompt_store::ProjectContext;
1238 use serde_json::json;
1239 use settings::Settings;
1240 use settings::SettingsStore;
1241 use util::path;
1242 use util::rel_path::rel_path;
1243
1244 #[gpui::test]
1245 async fn test_streaming_edit_create_file(cx: &mut TestAppContext) {
1246 let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
1247 let result = cx
1248 .update(|cx| {
1249 tool.clone().run(
1250 ToolInput::resolved(StreamingEditFileToolInput {
1251 display_description: "Create new file".into(),
1252 path: "root/dir/new_file.txt".into(),
1253 mode: StreamingEditFileMode::Write,
1254 content: Some("Hello, World!".into()),
1255 edits: None,
1256 }),
1257 ToolCallEventStream::test().0,
1258 cx,
1259 )
1260 })
1261 .await;
1262
1263 let StreamingEditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else {
1264 panic!("expected success");
1265 };
1266 assert_eq!(new_text, "Hello, World!");
1267 assert!(!diff.is_empty());
1268 }
1269
1270 #[gpui::test]
1271 async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) {
1272 let (tool, _project, _action_log, _fs, _thread) =
1273 setup_test(cx, json!({"file.txt": "old content"})).await;
1274 let result = cx
1275 .update(|cx| {
1276 tool.clone().run(
1277 ToolInput::resolved(StreamingEditFileToolInput {
1278 display_description: "Overwrite file".into(),
1279 path: "root/file.txt".into(),
1280 mode: StreamingEditFileMode::Write,
1281 content: Some("new content".into()),
1282 edits: None,
1283 }),
1284 ToolCallEventStream::test().0,
1285 cx,
1286 )
1287 })
1288 .await;
1289
1290 let StreamingEditFileToolOutput::Success {
1291 new_text, old_text, ..
1292 } = result.unwrap()
1293 else {
1294 panic!("expected success");
1295 };
1296 assert_eq!(new_text, "new content");
1297 assert_eq!(*old_text, "old content");
1298 }
1299
1300 #[gpui::test]
1301 async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) {
1302 let (tool, _project, _action_log, _fs, _thread) =
1303 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1304 let result = cx
1305 .update(|cx| {
1306 tool.clone().run(
1307 ToolInput::resolved(StreamingEditFileToolInput {
1308 display_description: "Edit lines".into(),
1309 path: "root/file.txt".into(),
1310 mode: StreamingEditFileMode::Edit,
1311 content: None,
1312 edits: Some(vec![Edit {
1313 old_text: "line 2".into(),
1314 new_text: "modified line 2".into(),
1315 }]),
1316 }),
1317 ToolCallEventStream::test().0,
1318 cx,
1319 )
1320 })
1321 .await;
1322
1323 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1324 panic!("expected success");
1325 };
1326 assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1327 }
1328
1329 #[gpui::test]
1330 async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) {
1331 let (tool, _project, _action_log, _fs, _thread) = setup_test(
1332 cx,
1333 json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
1334 )
1335 .await;
1336 let result = cx
1337 .update(|cx| {
1338 tool.clone().run(
1339 ToolInput::resolved(StreamingEditFileToolInput {
1340 display_description: "Edit multiple lines".into(),
1341 path: "root/file.txt".into(),
1342 mode: StreamingEditFileMode::Edit,
1343 content: None,
1344 edits: Some(vec![
1345 Edit {
1346 old_text: "line 5".into(),
1347 new_text: "modified line 5".into(),
1348 },
1349 Edit {
1350 old_text: "line 1".into(),
1351 new_text: "modified line 1".into(),
1352 },
1353 ]),
1354 }),
1355 ToolCallEventStream::test().0,
1356 cx,
1357 )
1358 })
1359 .await;
1360
1361 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1362 panic!("expected success");
1363 };
1364 assert_eq!(
1365 new_text,
1366 "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1367 );
1368 }
1369
1370 #[gpui::test]
1371 async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) {
1372 let (tool, _project, _action_log, _fs, _thread) = setup_test(
1373 cx,
1374 json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
1375 )
1376 .await;
1377 let result = cx
1378 .update(|cx| {
1379 tool.clone().run(
1380 ToolInput::resolved(StreamingEditFileToolInput {
1381 display_description: "Edit adjacent lines".into(),
1382 path: "root/file.txt".into(),
1383 mode: StreamingEditFileMode::Edit,
1384 content: None,
1385 edits: Some(vec![
1386 Edit {
1387 old_text: "line 2".into(),
1388 new_text: "modified line 2".into(),
1389 },
1390 Edit {
1391 old_text: "line 3".into(),
1392 new_text: "modified line 3".into(),
1393 },
1394 ]),
1395 }),
1396 ToolCallEventStream::test().0,
1397 cx,
1398 )
1399 })
1400 .await;
1401
1402 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1403 panic!("expected success");
1404 };
1405 assert_eq!(
1406 new_text,
1407 "line 1\nmodified line 2\nmodified line 3\nline 4\nline 5\n"
1408 );
1409 }
1410
1411 #[gpui::test]
1412 async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) {
1413 let (tool, _project, _action_log, _fs, _thread) = setup_test(
1414 cx,
1415 json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
1416 )
1417 .await;
1418 let result = cx
1419 .update(|cx| {
1420 tool.clone().run(
1421 ToolInput::resolved(StreamingEditFileToolInput {
1422 display_description: "Edit multiple lines in ascending order".into(),
1423 path: "root/file.txt".into(),
1424 mode: StreamingEditFileMode::Edit,
1425 content: None,
1426 edits: Some(vec![
1427 Edit {
1428 old_text: "line 1".into(),
1429 new_text: "modified line 1".into(),
1430 },
1431 Edit {
1432 old_text: "line 5".into(),
1433 new_text: "modified line 5".into(),
1434 },
1435 ]),
1436 }),
1437 ToolCallEventStream::test().0,
1438 cx,
1439 )
1440 })
1441 .await;
1442
1443 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1444 panic!("expected success");
1445 };
1446 assert_eq!(
1447 new_text,
1448 "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1449 );
1450 }
1451
1452 #[gpui::test]
1453 async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) {
1454 let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await;
1455 let result = cx
1456 .update(|cx| {
1457 tool.clone().run(
1458 ToolInput::resolved(StreamingEditFileToolInput {
1459 display_description: "Some edit".into(),
1460 path: "root/nonexistent_file.txt".into(),
1461 mode: StreamingEditFileMode::Edit,
1462 content: None,
1463 edits: Some(vec![Edit {
1464 old_text: "foo".into(),
1465 new_text: "bar".into(),
1466 }]),
1467 }),
1468 ToolCallEventStream::test().0,
1469 cx,
1470 )
1471 })
1472 .await;
1473
1474 let StreamingEditFileToolOutput::Error {
1475 error,
1476 diff,
1477 input_path,
1478 } = result.unwrap_err()
1479 else {
1480 panic!("expected error");
1481 };
1482 assert_eq!(error, "Can't edit file: path not found");
1483 assert!(diff.is_empty());
1484 assert_eq!(input_path, None);
1485 }
1486
1487 #[gpui::test]
1488 async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) {
1489 let (tool, _project, _action_log, _fs, _thread) =
1490 setup_test(cx, json!({"file.txt": "hello world"})).await;
1491 let result = cx
1492 .update(|cx| {
1493 tool.clone().run(
1494 ToolInput::resolved(StreamingEditFileToolInput {
1495 display_description: "Edit file".into(),
1496 path: "root/file.txt".into(),
1497 mode: StreamingEditFileMode::Edit,
1498 content: None,
1499 edits: Some(vec![Edit {
1500 old_text: "nonexistent text that is not in the file".into(),
1501 new_text: "replacement".into(),
1502 }]),
1503 }),
1504 ToolCallEventStream::test().0,
1505 cx,
1506 )
1507 })
1508 .await;
1509
1510 let StreamingEditFileToolOutput::Error { error, .. } = result.unwrap_err() else {
1511 panic!("expected error");
1512 };
1513 assert!(
1514 error.contains("Could not find matching text"),
1515 "Expected error containing 'Could not find matching text' but got: {error}"
1516 );
1517 }
1518
1519 #[gpui::test]
1520 async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {
1521 let (tool, _project, _action_log, _fs, _thread) =
1522 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1523 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1524 let (event_stream, _receiver) = ToolCallEventStream::test();
1525 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1526
1527 // Send partials simulating LLM streaming: description first, then path, then mode
1528 sender.send_partial(json!({"display_description": "Edit lines"}));
1529 cx.run_until_parked();
1530
1531 sender.send_partial(json!({
1532 "display_description": "Edit lines",
1533 "path": "root/file.txt"
1534 }));
1535 cx.run_until_parked();
1536
1537 // Path is NOT yet complete because mode hasn't appeared — no buffer open yet
1538 sender.send_partial(json!({
1539 "display_description": "Edit lines",
1540 "path": "root/file.txt",
1541 "mode": "edit"
1542 }));
1543 cx.run_until_parked();
1544
1545 // Now send the final complete input
1546 sender.send_full(json!({
1547 "display_description": "Edit lines",
1548 "path": "root/file.txt",
1549 "mode": "edit",
1550 "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
1551 }));
1552
1553 let result = task.await;
1554 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1555 panic!("expected success");
1556 };
1557 assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1558 }
1559
1560 #[gpui::test]
1561 async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) {
1562 let (tool, _project, _action_log, _fs, _thread) =
1563 setup_test(cx, json!({"file.txt": "hello world"})).await;
1564 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1565 let (event_stream, _receiver) = ToolCallEventStream::test();
1566 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1567
1568 // Send partial with path but NO mode — path should NOT be treated as complete
1569 sender.send_partial(json!({
1570 "display_description": "Overwrite file",
1571 "path": "root/file"
1572 }));
1573 cx.run_until_parked();
1574
1575 // Now the path grows and mode appears
1576 sender.send_partial(json!({
1577 "display_description": "Overwrite file",
1578 "path": "root/file.txt",
1579 "mode": "write"
1580 }));
1581 cx.run_until_parked();
1582
1583 // Send final
1584 sender.send_full(json!({
1585 "display_description": "Overwrite file",
1586 "path": "root/file.txt",
1587 "mode": "write",
1588 "content": "new content"
1589 }));
1590
1591 let result = task.await;
1592 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1593 panic!("expected success");
1594 };
1595 assert_eq!(new_text, "new content");
1596 }
1597
1598 #[gpui::test]
1599 async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) {
1600 let (tool, _project, _action_log, _fs, _thread) =
1601 setup_test(cx, json!({"file.txt": "hello world"})).await;
1602 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1603 let (event_stream, _receiver, mut cancellation_tx) =
1604 ToolCallEventStream::test_with_cancellation();
1605 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1606
1607 // Send a partial
1608 sender.send_partial(json!({"display_description": "Edit"}));
1609 cx.run_until_parked();
1610
1611 // Cancel during streaming
1612 ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx);
1613 cx.run_until_parked();
1614
1615 // The sender is still alive so the partial loop should detect cancellation
1616 // We need to drop the sender to also unblock recv() if the loop didn't catch it
1617 drop(sender);
1618
1619 let result = task.await;
1620 let StreamingEditFileToolOutput::Error { error, .. } = result.unwrap_err() else {
1621 panic!("expected error");
1622 };
1623 assert!(
1624 error.contains("cancelled"),
1625 "Expected cancellation error but got: {error}"
1626 );
1627 }
1628
1629 #[gpui::test]
1630 async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) {
1631 let (tool, _project, _action_log, _fs, _thread) = setup_test(
1632 cx,
1633 json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
1634 )
1635 .await;
1636 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1637 let (event_stream, _receiver) = ToolCallEventStream::test();
1638 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1639
1640 // Simulate fine-grained streaming of the JSON
1641 sender.send_partial(json!({"display_description": "Edit multiple"}));
1642 cx.run_until_parked();
1643
1644 sender.send_partial(json!({
1645 "display_description": "Edit multiple lines",
1646 "path": "root/file.txt"
1647 }));
1648 cx.run_until_parked();
1649
1650 sender.send_partial(json!({
1651 "display_description": "Edit multiple lines",
1652 "path": "root/file.txt",
1653 "mode": "edit"
1654 }));
1655 cx.run_until_parked();
1656
1657 sender.send_partial(json!({
1658 "display_description": "Edit multiple lines",
1659 "path": "root/file.txt",
1660 "mode": "edit",
1661 "edits": [{"old_text": "line 1"}]
1662 }));
1663 cx.run_until_parked();
1664
1665 sender.send_partial(json!({
1666 "display_description": "Edit multiple lines",
1667 "path": "root/file.txt",
1668 "mode": "edit",
1669 "edits": [
1670 {"old_text": "line 1", "new_text": "modified line 1"},
1671 {"old_text": "line 5"}
1672 ]
1673 }));
1674 cx.run_until_parked();
1675
1676 // Send final complete input
1677 sender.send_full(json!({
1678 "display_description": "Edit multiple lines",
1679 "path": "root/file.txt",
1680 "mode": "edit",
1681 "edits": [
1682 {"old_text": "line 1", "new_text": "modified line 1"},
1683 {"old_text": "line 5", "new_text": "modified line 5"}
1684 ]
1685 }));
1686
1687 let result = task.await;
1688 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1689 panic!("expected success");
1690 };
1691 assert_eq!(
1692 new_text,
1693 "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n"
1694 );
1695 }
1696
1697 #[gpui::test]
1698 async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) {
1699 let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
1700 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1701 let (event_stream, _receiver) = ToolCallEventStream::test();
1702 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1703
1704 // Stream partials for create mode
1705 sender.send_partial(json!({"display_description": "Create new file"}));
1706 cx.run_until_parked();
1707
1708 sender.send_partial(json!({
1709 "display_description": "Create new file",
1710 "path": "root/dir/new_file.txt",
1711 "mode": "write"
1712 }));
1713 cx.run_until_parked();
1714
1715 sender.send_partial(json!({
1716 "display_description": "Create new file",
1717 "path": "root/dir/new_file.txt",
1718 "mode": "write",
1719 "content": "Hello, "
1720 }));
1721 cx.run_until_parked();
1722
1723 // Final with full content
1724 sender.send_full(json!({
1725 "display_description": "Create new file",
1726 "path": "root/dir/new_file.txt",
1727 "mode": "write",
1728 "content": "Hello, World!"
1729 }));
1730
1731 let result = task.await;
1732 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1733 panic!("expected success");
1734 };
1735 assert_eq!(new_text, "Hello, World!");
1736 }
1737
1738 #[gpui::test]
1739 async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) {
1740 let (tool, _project, _action_log, _fs, _thread) =
1741 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1742 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1743 let (event_stream, _receiver) = ToolCallEventStream::test();
1744 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1745
1746 // Send final immediately with no partials (simulates non-streaming path)
1747 sender.send_full(json!({
1748 "display_description": "Edit lines",
1749 "path": "root/file.txt",
1750 "mode": "edit",
1751 "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
1752 }));
1753
1754 let result = task.await;
1755 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1756 panic!("expected success");
1757 };
1758 assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
1759 }
1760
1761 #[gpui::test]
1762 async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) {
1763 let (tool, project, _action_log, _fs, _thread) = setup_test(
1764 cx,
1765 json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}),
1766 )
1767 .await;
1768 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1769 let (event_stream, _receiver) = ToolCallEventStream::test();
1770 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1771
1772 // Stream description, path, mode
1773 sender.send_partial(json!({"display_description": "Edit multiple lines"}));
1774 cx.run_until_parked();
1775
1776 sender.send_partial(json!({
1777 "display_description": "Edit multiple lines",
1778 "path": "root/file.txt",
1779 "mode": "edit"
1780 }));
1781 cx.run_until_parked();
1782
1783 // First edit starts streaming (old_text only, still in progress)
1784 sender.send_partial(json!({
1785 "display_description": "Edit multiple lines",
1786 "path": "root/file.txt",
1787 "mode": "edit",
1788 "edits": [{"old_text": "line 1"}]
1789 }));
1790 cx.run_until_parked();
1791
1792 // Buffer should not have changed yet — the first edit is still in progress
1793 // (no second edit has appeared to prove the first is complete)
1794 let buffer_text = project.update(cx, |project, cx| {
1795 let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
1796 project_path.and_then(|pp| {
1797 project
1798 .get_open_buffer(&pp, cx)
1799 .map(|buffer| buffer.read(cx).text())
1800 })
1801 });
1802 // Buffer is open (from streaming) but edit 1 is still in-progress
1803 assert_eq!(
1804 buffer_text.as_deref(),
1805 Some("line 1\nline 2\nline 3\nline 4\nline 5\n"),
1806 "Buffer should not be modified while first edit is still in progress"
1807 );
1808
1809 // Second edit appears — this proves the first edit is complete, so it
1810 // should be applied immediately during streaming
1811 sender.send_partial(json!({
1812 "display_description": "Edit multiple lines",
1813 "path": "root/file.txt",
1814 "mode": "edit",
1815 "edits": [
1816 {"old_text": "line 1", "new_text": "MODIFIED 1"},
1817 {"old_text": "line 5"}
1818 ]
1819 }));
1820 cx.run_until_parked();
1821
1822 // First edit should now be applied to the buffer
1823 let buffer_text = project.update(cx, |project, cx| {
1824 let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx);
1825 project_path.and_then(|pp| {
1826 project
1827 .get_open_buffer(&pp, cx)
1828 .map(|buffer| buffer.read(cx).text())
1829 })
1830 });
1831 assert_eq!(
1832 buffer_text.as_deref(),
1833 Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"),
1834 "First edit should be applied during streaming when second edit appears"
1835 );
1836
1837 // Send final complete input
1838 sender.send_full(json!({
1839 "display_description": "Edit multiple lines",
1840 "path": "root/file.txt",
1841 "mode": "edit",
1842 "edits": [
1843 {"old_text": "line 1", "new_text": "MODIFIED 1"},
1844 {"old_text": "line 5", "new_text": "MODIFIED 5"}
1845 ]
1846 }));
1847
1848 let result = task.await;
1849 let StreamingEditFileToolOutput::Success {
1850 new_text, old_text, ..
1851 } = result.unwrap()
1852 else {
1853 panic!("expected success");
1854 };
1855 assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n");
1856 assert_eq!(
1857 *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n",
1858 "old_text should reflect the original file content before any edits"
1859 );
1860 }
1861
1862 #[gpui::test]
1863 async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) {
1864 let (tool, project, _action_log, _fs, _thread) =
1865 setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
1866 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1867 let (event_stream, _receiver) = ToolCallEventStream::test();
1868 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1869
1870 // Setup: description + path + mode
1871 sender.send_partial(json!({
1872 "display_description": "Edit three lines",
1873 "path": "root/file.txt",
1874 "mode": "edit"
1875 }));
1876 cx.run_until_parked();
1877
1878 // Edit 1 in progress
1879 sender.send_partial(json!({
1880 "display_description": "Edit three lines",
1881 "path": "root/file.txt",
1882 "mode": "edit",
1883 "edits": [{"old_text": "aaa", "new_text": "AAA"}]
1884 }));
1885 cx.run_until_parked();
1886
1887 // Edit 2 appears — edit 1 is now complete and should be applied
1888 sender.send_partial(json!({
1889 "display_description": "Edit three lines",
1890 "path": "root/file.txt",
1891 "mode": "edit",
1892 "edits": [
1893 {"old_text": "aaa", "new_text": "AAA"},
1894 {"old_text": "ccc", "new_text": "CCC"}
1895 ]
1896 }));
1897 cx.run_until_parked();
1898
1899 // Verify edit 1 fully applied. Edit 2's new_text is being
1900 // streamed: "CCC" is inserted but the old "ccc" isn't deleted
1901 // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done).
1902 let buffer_text = project.update(cx, |project, cx| {
1903 let pp = project
1904 .find_project_path(&PathBuf::from("root/file.txt"), cx)
1905 .unwrap();
1906 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
1907 });
1908 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n"));
1909
1910 // Edit 3 appears — edit 2 is now complete and should be applied
1911 sender.send_partial(json!({
1912 "display_description": "Edit three lines",
1913 "path": "root/file.txt",
1914 "mode": "edit",
1915 "edits": [
1916 {"old_text": "aaa", "new_text": "AAA"},
1917 {"old_text": "ccc", "new_text": "CCC"},
1918 {"old_text": "eee", "new_text": "EEE"}
1919 ]
1920 }));
1921 cx.run_until_parked();
1922
1923 // Verify edits 1 and 2 fully applied. Edit 3's new_text is being
1924 // streamed: "EEE" is inserted but old "eee" isn't deleted yet.
1925 let buffer_text = project.update(cx, |project, cx| {
1926 let pp = project
1927 .find_project_path(&PathBuf::from("root/file.txt"), cx)
1928 .unwrap();
1929 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
1930 });
1931 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n"));
1932
1933 // Send final
1934 sender.send_full(json!({
1935 "display_description": "Edit three lines",
1936 "path": "root/file.txt",
1937 "mode": "edit",
1938 "edits": [
1939 {"old_text": "aaa", "new_text": "AAA"},
1940 {"old_text": "ccc", "new_text": "CCC"},
1941 {"old_text": "eee", "new_text": "EEE"}
1942 ]
1943 }));
1944
1945 let result = task.await;
1946 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
1947 panic!("expected success");
1948 };
1949 assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n");
1950 }
1951
1952 #[gpui::test]
1953 async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) {
1954 let (tool, project, _action_log, _fs, _thread) =
1955 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
1956 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
1957 let (event_stream, _receiver) = ToolCallEventStream::test();
1958 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
1959
1960 // Setup
1961 sender.send_partial(json!({
1962 "display_description": "Edit lines",
1963 "path": "root/file.txt",
1964 "mode": "edit"
1965 }));
1966 cx.run_until_parked();
1967
1968 // Edit 1 (valid) in progress — not yet complete (no second edit)
1969 sender.send_partial(json!({
1970 "display_description": "Edit lines",
1971 "path": "root/file.txt",
1972 "mode": "edit",
1973 "edits": [
1974 {"old_text": "line 1", "new_text": "MODIFIED"}
1975 ]
1976 }));
1977 cx.run_until_parked();
1978
1979 // Edit 2 appears (will fail to match) — this makes edit 1 complete.
1980 // Edit 1 should be applied. Edit 2 is still in-progress (last edit).
1981 sender.send_partial(json!({
1982 "display_description": "Edit lines",
1983 "path": "root/file.txt",
1984 "mode": "edit",
1985 "edits": [
1986 {"old_text": "line 1", "new_text": "MODIFIED"},
1987 {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}
1988 ]
1989 }));
1990 cx.run_until_parked();
1991
1992 let buffer = project.update(cx, |project, cx| {
1993 let pp = project
1994 .find_project_path(&PathBuf::from("root/file.txt"), cx)
1995 .unwrap();
1996 project.get_open_buffer(&pp, cx).unwrap()
1997 });
1998
1999 // Verify edit 1 was applied
2000 let buffer_text = buffer.read_with(cx, |buffer, _cx| buffer.text());
2001 assert_eq!(
2002 buffer_text, "MODIFIED\nline 2\nline 3\n",
2003 "First edit should be applied even though second edit will fail"
2004 );
2005
2006 // Edit 3 appears — this makes edit 2 "complete", triggering its
2007 // resolution which should fail (old_text doesn't exist in the file).
2008 sender.send_partial(json!({
2009 "display_description": "Edit lines",
2010 "path": "root/file.txt",
2011 "mode": "edit",
2012 "edits": [
2013 {"old_text": "line 1", "new_text": "MODIFIED"},
2014 {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"},
2015 {"old_text": "line 3", "new_text": "MODIFIED 3"}
2016 ]
2017 }));
2018 cx.run_until_parked();
2019
2020 // The error from edit 2 should have propagated out of the partial loop.
2021 // Drop sender to unblock recv() if the loop didn't catch it.
2022 drop(sender);
2023
2024 let result = task.await;
2025 let StreamingEditFileToolOutput::Error {
2026 error,
2027 diff,
2028 input_path,
2029 } = result.unwrap_err()
2030 else {
2031 panic!("expected error");
2032 };
2033
2034 assert!(
2035 error.contains("Could not find matching text for edit at index 1"),
2036 "Expected error about edit 1 failing, got: {error}"
2037 );
2038 // Ensure that first edit was applied successfully and that we saved the buffer
2039 assert_eq!(input_path, Some(PathBuf::from("root/file.txt")));
2040 assert_eq!(
2041 diff,
2042 "@@ -1,3 +1,3 @@\n-line 1\n+MODIFIED\n line 2\n line 3\n"
2043 );
2044 }
2045
2046 #[gpui::test]
2047 async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {
2048 let (tool, project, _action_log, _fs, _thread) =
2049 setup_test(cx, json!({"file.txt": "hello world\n"})).await;
2050 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2051 let (event_stream, _receiver) = ToolCallEventStream::test();
2052 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2053
2054 // Setup + single edit that stays in-progress (no second edit to prove completion)
2055 sender.send_partial(json!({
2056 "display_description": "Single edit",
2057 "path": "root/file.txt",
2058 "mode": "edit",
2059 }));
2060 cx.run_until_parked();
2061
2062 sender.send_partial(json!({
2063 "display_description": "Single edit",
2064 "path": "root/file.txt",
2065 "mode": "edit",
2066 "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
2067 }));
2068 cx.run_until_parked();
2069
2070 // The edit's old_text and new_text both arrived in one partial, so
2071 // the old_text is resolved and new_text is being streamed via
2072 // StreamingDiff. The buffer reflects the in-progress diff (new text
2073 // inserted, old text not yet fully removed until finalization).
2074 let buffer_text = project.update(cx, |project, cx| {
2075 let pp = project
2076 .find_project_path(&PathBuf::from("root/file.txt"), cx)
2077 .unwrap();
2078 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2079 });
2080 assert_eq!(
2081 buffer_text.as_deref(),
2082 Some("goodbye worldhello world\n"),
2083 "In-progress streaming diff: new text inserted, old text not yet removed"
2084 );
2085
2086 // Send final — the edit is applied during finalization
2087 sender.send_full(json!({
2088 "display_description": "Single edit",
2089 "path": "root/file.txt",
2090 "mode": "edit",
2091 "edits": [{"old_text": "hello world", "new_text": "goodbye world"}]
2092 }));
2093
2094 let result = task.await;
2095 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2096 panic!("expected success");
2097 };
2098 assert_eq!(new_text, "goodbye world\n");
2099 }
2100
2101 #[gpui::test]
2102 async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) {
2103 let (tool, _project, _action_log, _fs, _thread) =
2104 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
2105 let (mut sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2106 ToolInput::test();
2107 let (event_stream, _event_rx) = ToolCallEventStream::test();
2108 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2109
2110 // Send progressively more complete partial snapshots, as the LLM would
2111 sender.send_partial(json!({
2112 "display_description": "Edit lines"
2113 }));
2114 cx.run_until_parked();
2115
2116 sender.send_partial(json!({
2117 "display_description": "Edit lines",
2118 "path": "root/file.txt",
2119 "mode": "edit"
2120 }));
2121 cx.run_until_parked();
2122
2123 sender.send_partial(json!({
2124 "display_description": "Edit lines",
2125 "path": "root/file.txt",
2126 "mode": "edit",
2127 "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
2128 }));
2129 cx.run_until_parked();
2130
2131 // Send the final complete input
2132 sender.send_full(json!({
2133 "display_description": "Edit lines",
2134 "path": "root/file.txt",
2135 "mode": "edit",
2136 "edits": [{"old_text": "line 2", "new_text": "modified line 2"}]
2137 }));
2138
2139 let result = task.await;
2140 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2141 panic!("expected success");
2142 };
2143 assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
2144 }
2145
2146 #[gpui::test]
2147 async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) {
2148 let (tool, _project, _action_log, _fs, _thread) =
2149 setup_test(cx, json!({"file.txt": "hello world\n"})).await;
2150 let (mut sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2151 ToolInput::test();
2152 let (event_stream, _event_rx) = ToolCallEventStream::test();
2153 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2154
2155 // Send a partial then drop the sender without sending final
2156 sender.send_partial(json!({
2157 "display_description": "Edit file"
2158 }));
2159 cx.run_until_parked();
2160
2161 drop(sender);
2162
2163 let result = task.await;
2164 assert!(
2165 result.is_err(),
2166 "Tool should error when sender is dropped without sending final input"
2167 );
2168 }
2169
2170 #[gpui::test]
2171 async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) {
2172 let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
2173 // Create a channel and send multiple partials before a final, then use
2174 // ToolInput::resolved-style immediate delivery to confirm recv() works
2175 // when partials are already buffered.
2176 let (mut sender, input): (ToolInputSender, ToolInput<StreamingEditFileToolInput>) =
2177 ToolInput::test();
2178 let (event_stream, _event_rx) = ToolCallEventStream::test();
2179 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2180
2181 // Buffer several partials before sending the final
2182 sender.send_partial(json!({"display_description": "Create"}));
2183 sender.send_partial(json!({"display_description": "Create", "path": "root/dir/new.txt"}));
2184 sender.send_partial(json!({
2185 "display_description": "Create",
2186 "path": "root/dir/new.txt",
2187 "mode": "write"
2188 }));
2189 sender.send_full(json!({
2190 "display_description": "Create",
2191 "path": "root/dir/new.txt",
2192 "mode": "write",
2193 "content": "streamed content"
2194 }));
2195
2196 let result = task.await;
2197 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
2198 panic!("expected success");
2199 };
2200 assert_eq!(new_text, "streamed content");
2201 }
2202
2203 #[gpui::test]
2204 async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {
2205 let mode = StreamingEditFileMode::Write;
2206
2207 let result = test_resolve_path(&mode, "root/new.txt", cx);
2208 assert_resolved_path_eq(result.await, rel_path("new.txt"));
2209
2210 let result = test_resolve_path(&mode, "new.txt", cx);
2211 assert_resolved_path_eq(result.await, rel_path("new.txt"));
2212
2213 let result = test_resolve_path(&mode, "dir/new.txt", cx);
2214 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
2215
2216 let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx);
2217 assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt"));
2218
2219 let result = test_resolve_path(&mode, "root/dir/subdir", cx);
2220 assert_eq!(
2221 result.await.unwrap_err(),
2222 "Can't write to file: path is a directory"
2223 );
2224
2225 let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);
2226 assert_eq!(
2227 result.await.unwrap_err(),
2228 "Can't create file: parent directory doesn't exist"
2229 );
2230 }
2231
2232 #[gpui::test]
2233 async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) {
2234 let mode = StreamingEditFileMode::Edit;
2235
2236 let path_with_root = "root/dir/subdir/existing.txt";
2237 let path_without_root = "dir/subdir/existing.txt";
2238 let result = test_resolve_path(&mode, path_with_root, cx);
2239 assert_resolved_path_eq(result.await, rel_path(path_without_root));
2240
2241 let result = test_resolve_path(&mode, path_without_root, cx);
2242 assert_resolved_path_eq(result.await, rel_path(path_without_root));
2243
2244 let result = test_resolve_path(&mode, "root/nonexistent.txt", cx);
2245 assert_eq!(result.await.unwrap_err(), "Can't edit file: path not found");
2246
2247 let result = test_resolve_path(&mode, "root/dir", cx);
2248 assert_eq!(
2249 result.await.unwrap_err(),
2250 "Can't edit file: path is a directory"
2251 );
2252 }
2253
2254 async fn test_resolve_path(
2255 mode: &StreamingEditFileMode,
2256 path: &str,
2257 cx: &mut TestAppContext,
2258 ) -> Result<ProjectPath, String> {
2259 init_test(cx);
2260
2261 let fs = project::FakeFs::new(cx.executor());
2262 fs.insert_tree(
2263 "/root",
2264 json!({
2265 "dir": {
2266 "subdir": {
2267 "existing.txt": "hello"
2268 }
2269 }
2270 }),
2271 )
2272 .await;
2273 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2274
2275 cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), &project, cx))
2276 }
2277
2278 #[track_caller]
2279 fn assert_resolved_path_eq(path: Result<ProjectPath, String>, expected: &RelPath) {
2280 let actual = path.expect("Should return valid path").path;
2281 assert_eq!(actual.as_ref(), expected);
2282 }
2283
2284 #[gpui::test]
2285 async fn test_streaming_format_on_save(cx: &mut TestAppContext) {
2286 init_test(cx);
2287
2288 let fs = project::FakeFs::new(cx.executor());
2289 fs.insert_tree("/root", json!({"src": {}})).await;
2290 let (tool, project, action_log, fs, thread) =
2291 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2292
2293 let rust_language = Arc::new(language::Language::new(
2294 language::LanguageConfig {
2295 name: "Rust".into(),
2296 matcher: language::LanguageMatcher {
2297 path_suffixes: vec!["rs".to_string()],
2298 ..Default::default()
2299 },
2300 ..Default::default()
2301 },
2302 None,
2303 ));
2304
2305 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
2306 language_registry.add(rust_language);
2307
2308 let mut fake_language_servers = language_registry.register_fake_lsp(
2309 "Rust",
2310 language::FakeLspAdapter {
2311 capabilities: lsp::ServerCapabilities {
2312 document_formatting_provider: Some(lsp::OneOf::Left(true)),
2313 ..Default::default()
2314 },
2315 ..Default::default()
2316 },
2317 );
2318
2319 fs.save(
2320 path!("/root/src/main.rs").as_ref(),
2321 &"initial content".into(),
2322 language::LineEnding::Unix,
2323 )
2324 .await
2325 .unwrap();
2326
2327 // Open the buffer to trigger LSP initialization
2328 let buffer = project
2329 .update(cx, |project, cx| {
2330 project.open_local_buffer(path!("/root/src/main.rs"), cx)
2331 })
2332 .await
2333 .unwrap();
2334
2335 // Register the buffer with language servers
2336 let _handle = project.update(cx, |project, cx| {
2337 project.register_buffer_with_language_servers(&buffer, cx)
2338 });
2339
2340 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\
2341";
2342 const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\
2343";
2344
2345 // Get the fake language server and set up formatting handler
2346 let fake_language_server = fake_language_servers.next().await.unwrap();
2347 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
2348 |_, _| async move {
2349 Ok(Some(vec![lsp::TextEdit {
2350 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
2351 new_text: FORMATTED_CONTENT.to_string(),
2352 }]))
2353 }
2354 });
2355
2356 // Test with format_on_save enabled
2357 cx.update(|cx| {
2358 SettingsStore::update_global(cx, |store, cx| {
2359 store.update_user_settings(cx, |settings| {
2360 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
2361 settings.project.all_languages.defaults.formatter =
2362 Some(language::language_settings::FormatterList::default());
2363 });
2364 });
2365 });
2366
2367 // Use streaming pattern so executor can pump the LSP request/response
2368 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2369 let (event_stream, _receiver) = ToolCallEventStream::test();
2370
2371 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2372
2373 sender.send_partial(json!({
2374 "display_description": "Create main function",
2375 "path": "root/src/main.rs",
2376 "mode": "write"
2377 }));
2378 cx.run_until_parked();
2379
2380 sender.send_full(json!({
2381 "display_description": "Create main function",
2382 "path": "root/src/main.rs",
2383 "mode": "write",
2384 "content": UNFORMATTED_CONTENT
2385 }));
2386
2387 let result = task.await;
2388 assert!(result.is_ok());
2389
2390 cx.executor().run_until_parked();
2391
2392 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
2393 assert_eq!(
2394 new_content.replace("\r\n", "\n"),
2395 FORMATTED_CONTENT,
2396 "Code should be formatted when format_on_save is enabled"
2397 );
2398
2399 let stale_buffer_count = thread
2400 .read_with(cx, |thread, _cx| thread.action_log.clone())
2401 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
2402
2403 assert_eq!(
2404 stale_buffer_count, 0,
2405 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.",
2406 stale_buffer_count
2407 );
2408
2409 // Test with format_on_save disabled
2410 cx.update(|cx| {
2411 SettingsStore::update_global(cx, |store, cx| {
2412 store.update_user_settings(cx, |settings| {
2413 settings.project.all_languages.defaults.format_on_save =
2414 Some(FormatOnSave::Off);
2415 });
2416 });
2417 });
2418
2419 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2420 let (event_stream, _receiver) = ToolCallEventStream::test();
2421
2422 let tool2 = Arc::new(StreamingEditFileTool::new(
2423 project.clone(),
2424 thread.downgrade(),
2425 action_log.clone(),
2426 language_registry,
2427 ));
2428
2429 let task = cx.update(|cx| tool2.run(input, event_stream, cx));
2430
2431 sender.send_partial(json!({
2432 "display_description": "Update main function",
2433 "path": "root/src/main.rs",
2434 "mode": "write"
2435 }));
2436 cx.run_until_parked();
2437
2438 sender.send_full(json!({
2439 "display_description": "Update main function",
2440 "path": "root/src/main.rs",
2441 "mode": "write",
2442 "content": UNFORMATTED_CONTENT
2443 }));
2444
2445 let result = task.await;
2446 assert!(result.is_ok());
2447
2448 cx.executor().run_until_parked();
2449
2450 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
2451 assert_eq!(
2452 new_content.replace("\r\n", "\n"),
2453 UNFORMATTED_CONTENT,
2454 "Code should not be formatted when format_on_save is disabled"
2455 );
2456 }
2457
2458 #[gpui::test]
2459 async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) {
2460 init_test(cx);
2461
2462 let fs = project::FakeFs::new(cx.executor());
2463 fs.insert_tree("/root", json!({"src": {}})).await;
2464 fs.save(
2465 path!("/root/src/main.rs").as_ref(),
2466 &"initial content".into(),
2467 language::LineEnding::Unix,
2468 )
2469 .await
2470 .unwrap();
2471 let (tool, project, action_log, fs, thread) =
2472 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2473 let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
2474
2475 // Test with remove_trailing_whitespace_on_save enabled
2476 cx.update(|cx| {
2477 SettingsStore::update_global(cx, |store, cx| {
2478 store.update_user_settings(cx, |settings| {
2479 settings
2480 .project
2481 .all_languages
2482 .defaults
2483 .remove_trailing_whitespace_on_save = Some(true);
2484 });
2485 });
2486 });
2487
2488 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
2489 "fn main() { \n println!(\"Hello!\"); \n}\n";
2490
2491 let result = cx
2492 .update(|cx| {
2493 tool.clone().run(
2494 ToolInput::resolved(StreamingEditFileToolInput {
2495 display_description: "Create main function".into(),
2496 path: "root/src/main.rs".into(),
2497 mode: StreamingEditFileMode::Write,
2498 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
2499 edits: None,
2500 }),
2501 ToolCallEventStream::test().0,
2502 cx,
2503 )
2504 })
2505 .await;
2506 assert!(result.is_ok());
2507
2508 cx.executor().run_until_parked();
2509
2510 assert_eq!(
2511 fs.load(path!("/root/src/main.rs").as_ref())
2512 .await
2513 .unwrap()
2514 .replace("\r\n", "\n"),
2515 "fn main() {\n println!(\"Hello!\");\n}\n",
2516 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
2517 );
2518
2519 // Test with remove_trailing_whitespace_on_save disabled
2520 cx.update(|cx| {
2521 SettingsStore::update_global(cx, |store, cx| {
2522 store.update_user_settings(cx, |settings| {
2523 settings
2524 .project
2525 .all_languages
2526 .defaults
2527 .remove_trailing_whitespace_on_save = Some(false);
2528 });
2529 });
2530 });
2531
2532 let tool2 = Arc::new(StreamingEditFileTool::new(
2533 project.clone(),
2534 thread.downgrade(),
2535 action_log.clone(),
2536 language_registry,
2537 ));
2538
2539 let result = cx
2540 .update(|cx| {
2541 tool2.run(
2542 ToolInput::resolved(StreamingEditFileToolInput {
2543 display_description: "Update main function".into(),
2544 path: "root/src/main.rs".into(),
2545 mode: StreamingEditFileMode::Write,
2546 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
2547 edits: None,
2548 }),
2549 ToolCallEventStream::test().0,
2550 cx,
2551 )
2552 })
2553 .await;
2554 assert!(result.is_ok());
2555
2556 cx.executor().run_until_parked();
2557
2558 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
2559 assert_eq!(
2560 final_content.replace("\r\n", "\n"),
2561 CONTENT_WITH_TRAILING_WHITESPACE,
2562 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
2563 );
2564 }
2565
2566 #[gpui::test]
2567 async fn test_streaming_authorize(cx: &mut TestAppContext) {
2568 let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await;
2569
2570 // Test 1: Path with .zed component should require confirmation
2571 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2572 let _auth = cx.update(|cx| {
2573 tool.authorize(
2574 &PathBuf::from(".zed/settings.json"),
2575 "test 1",
2576 &stream_tx,
2577 cx,
2578 )
2579 });
2580
2581 let event = stream_rx.expect_authorization().await;
2582 assert_eq!(
2583 event.tool_call.fields.title,
2584 Some("test 1 (local settings)".into())
2585 );
2586
2587 // Test 2: Path outside project should require confirmation
2588 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2589 let _auth =
2590 cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 2", &stream_tx, cx));
2591
2592 let event = stream_rx.expect_authorization().await;
2593 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
2594
2595 // Test 3: Relative path without .zed should not require confirmation
2596 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2597 cx.update(|cx| {
2598 tool.authorize(&PathBuf::from("root/src/main.rs"), "test 3", &stream_tx, cx)
2599 })
2600 .await
2601 .unwrap();
2602 assert!(stream_rx.try_recv().is_err());
2603
2604 // Test 4: Path with .zed in the middle should require confirmation
2605 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2606 let _auth = cx.update(|cx| {
2607 tool.authorize(
2608 &PathBuf::from("root/.zed/tasks.json"),
2609 "test 4",
2610 &stream_tx,
2611 cx,
2612 )
2613 });
2614 let event = stream_rx.expect_authorization().await;
2615 assert_eq!(
2616 event.tool_call.fields.title,
2617 Some("test 4 (local settings)".into())
2618 );
2619
2620 // Test 5: When global default is allow, sensitive and outside-project
2621 // paths still require confirmation
2622 cx.update(|cx| {
2623 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2624 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
2625 agent_settings::AgentSettings::override_global(settings, cx);
2626 });
2627
2628 // 5.1: .zed/settings.json is a sensitive path — still prompts
2629 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2630 let _auth = cx.update(|cx| {
2631 tool.authorize(
2632 &PathBuf::from(".zed/settings.json"),
2633 "test 5.1",
2634 &stream_tx,
2635 cx,
2636 )
2637 });
2638 let event = stream_rx.expect_authorization().await;
2639 assert_eq!(
2640 event.tool_call.fields.title,
2641 Some("test 5.1 (local settings)".into())
2642 );
2643
2644 // 5.2: /etc/hosts is outside the project, but Allow auto-approves
2645 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2646 cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx))
2647 .await
2648 .unwrap();
2649 assert!(stream_rx.try_recv().is_err());
2650
2651 // 5.3: Normal in-project path with allow — no confirmation needed
2652 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2653 cx.update(|cx| {
2654 tool.authorize(
2655 &PathBuf::from("root/src/main.rs"),
2656 "test 5.3",
2657 &stream_tx,
2658 cx,
2659 )
2660 })
2661 .await
2662 .unwrap();
2663 assert!(stream_rx.try_recv().is_err());
2664
2665 // 5.4: With Confirm default, non-project paths still prompt
2666 cx.update(|cx| {
2667 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2668 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
2669 agent_settings::AgentSettings::override_global(settings, cx);
2670 });
2671
2672 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2673 let _auth = cx
2674 .update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.4", &stream_tx, cx));
2675
2676 let event = stream_rx.expect_authorization().await;
2677 assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
2678 }
2679
2680 #[gpui::test]
2681 async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) {
2682 init_test(cx);
2683
2684 let fs = project::FakeFs::new(cx.executor());
2685 fs.insert_tree("/root", json!({})).await;
2686 fs.insert_tree("/outside", json!({})).await;
2687 fs.insert_symlink("/root/link", PathBuf::from("/outside"))
2688 .await;
2689 let (tool, _project, _action_log, _fs, _thread) =
2690 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2691
2692 cx.update(|cx| {
2693 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2694 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
2695 agent_settings::AgentSettings::override_global(settings, cx);
2696 });
2697
2698 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2699 let authorize_task = cx.update(|cx| {
2700 tool.authorize(
2701 &PathBuf::from("link/new.txt"),
2702 "create through symlink",
2703 &stream_tx,
2704 cx,
2705 )
2706 });
2707
2708 let event = stream_rx.expect_authorization().await;
2709 assert!(
2710 event
2711 .tool_call
2712 .fields
2713 .title
2714 .as_deref()
2715 .is_some_and(|title| title.contains("points outside the project")),
2716 "Expected symlink escape authorization for create under external symlink"
2717 );
2718
2719 event
2720 .response
2721 .send(acp_thread::SelectedPermissionOutcome::new(
2722 acp::PermissionOptionId::new("allow"),
2723 acp::PermissionOptionKind::AllowOnce,
2724 ))
2725 .unwrap();
2726 authorize_task.await.unwrap();
2727 }
2728
2729 #[gpui::test]
2730 async fn test_streaming_edit_file_symlink_escape_requests_authorization(
2731 cx: &mut TestAppContext,
2732 ) {
2733 init_test(cx);
2734
2735 let fs = project::FakeFs::new(cx.executor());
2736 fs.insert_tree(
2737 path!("/root"),
2738 json!({
2739 "src": { "main.rs": "fn main() {}" }
2740 }),
2741 )
2742 .await;
2743 fs.insert_tree(
2744 path!("/outside"),
2745 json!({
2746 "config.txt": "old content"
2747 }),
2748 )
2749 .await;
2750 fs.create_symlink(
2751 path!("/root/link_to_external").as_ref(),
2752 PathBuf::from("/outside"),
2753 )
2754 .await
2755 .unwrap();
2756 let (tool, _project, _action_log, _fs, _thread) =
2757 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2758
2759 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2760 let _authorize_task = cx.update(|cx| {
2761 tool.authorize(
2762 &PathBuf::from("link_to_external/config.txt"),
2763 "edit through symlink",
2764 &stream_tx,
2765 cx,
2766 )
2767 });
2768
2769 let auth = stream_rx.expect_authorization().await;
2770 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
2771 assert!(
2772 title.contains("points outside the project"),
2773 "title should mention symlink escape, got: {title}"
2774 );
2775 }
2776
2777 #[gpui::test]
2778 async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
2779 init_test(cx);
2780
2781 let fs = project::FakeFs::new(cx.executor());
2782 fs.insert_tree(
2783 path!("/root"),
2784 json!({
2785 "src": { "main.rs": "fn main() {}" }
2786 }),
2787 )
2788 .await;
2789 fs.insert_tree(
2790 path!("/outside"),
2791 json!({
2792 "config.txt": "old content"
2793 }),
2794 )
2795 .await;
2796 fs.create_symlink(
2797 path!("/root/link_to_external").as_ref(),
2798 PathBuf::from("/outside"),
2799 )
2800 .await
2801 .unwrap();
2802 let (tool, _project, _action_log, _fs, _thread) =
2803 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2804
2805 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2806 let authorize_task = cx.update(|cx| {
2807 tool.authorize(
2808 &PathBuf::from("link_to_external/config.txt"),
2809 "edit through symlink",
2810 &stream_tx,
2811 cx,
2812 )
2813 });
2814
2815 let auth = stream_rx.expect_authorization().await;
2816 drop(auth); // deny by dropping
2817
2818 let result = authorize_task.await;
2819 assert!(result.is_err(), "should fail when denied");
2820 }
2821
2822 #[gpui::test]
2823 async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
2824 init_test(cx);
2825 cx.update(|cx| {
2826 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2827 settings.tool_permissions.tools.insert(
2828 "edit_file".into(),
2829 agent_settings::ToolRules {
2830 default: Some(settings::ToolPermissionMode::Deny),
2831 ..Default::default()
2832 },
2833 );
2834 agent_settings::AgentSettings::override_global(settings, cx);
2835 });
2836
2837 let fs = project::FakeFs::new(cx.executor());
2838 fs.insert_tree(
2839 path!("/root"),
2840 json!({
2841 "src": { "main.rs": "fn main() {}" }
2842 }),
2843 )
2844 .await;
2845 fs.insert_tree(
2846 path!("/outside"),
2847 json!({
2848 "config.txt": "old content"
2849 }),
2850 )
2851 .await;
2852 fs.create_symlink(
2853 path!("/root/link_to_external").as_ref(),
2854 PathBuf::from("/outside"),
2855 )
2856 .await
2857 .unwrap();
2858 let (tool, _project, _action_log, _fs, _thread) =
2859 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2860
2861 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2862 let result = cx
2863 .update(|cx| {
2864 tool.authorize(
2865 &PathBuf::from("link_to_external/config.txt"),
2866 "edit through symlink",
2867 &stream_tx,
2868 cx,
2869 )
2870 })
2871 .await;
2872
2873 assert!(result.is_err(), "Tool should fail when policy denies");
2874 assert!(
2875 !matches!(
2876 stream_rx.try_recv(),
2877 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
2878 ),
2879 "Deny policy should not emit symlink authorization prompt",
2880 );
2881 }
2882
2883 #[gpui::test]
2884 async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) {
2885 init_test(cx);
2886 let fs = project::FakeFs::new(cx.executor());
2887 fs.insert_tree("/project", json!({})).await;
2888 let (tool, _project, _action_log, _fs, _thread) =
2889 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2890
2891 let test_cases = vec![
2892 (
2893 "/etc/hosts",
2894 true,
2895 "System file should require confirmation",
2896 ),
2897 (
2898 "/usr/local/bin/script",
2899 true,
2900 "System bin file should require confirmation",
2901 ),
2902 (
2903 "project/normal_file.rs",
2904 false,
2905 "Normal project file should not require confirmation",
2906 ),
2907 ];
2908
2909 for (path, should_confirm, description) in test_cases {
2910 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2911 let auth =
2912 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2913
2914 if should_confirm {
2915 stream_rx.expect_authorization().await;
2916 } else {
2917 auth.await.unwrap();
2918 assert!(
2919 stream_rx.try_recv().is_err(),
2920 "Failed for case: {} - path: {} - expected no confirmation but got one",
2921 description,
2922 path
2923 );
2924 }
2925 }
2926 }
2927
2928 #[gpui::test]
2929 async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2930 init_test(cx);
2931 let fs = project::FakeFs::new(cx.executor());
2932 fs.insert_tree(
2933 "/workspace/frontend",
2934 json!({
2935 "src": {
2936 "main.js": "console.log('frontend');"
2937 }
2938 }),
2939 )
2940 .await;
2941 fs.insert_tree(
2942 "/workspace/backend",
2943 json!({
2944 "src": {
2945 "main.rs": "fn main() {}"
2946 }
2947 }),
2948 )
2949 .await;
2950 fs.insert_tree(
2951 "/workspace/shared",
2952 json!({
2953 ".zed": {
2954 "settings.json": "{}"
2955 }
2956 }),
2957 )
2958 .await;
2959 let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(
2960 cx,
2961 fs,
2962 &[
2963 path!("/workspace/frontend").as_ref(),
2964 path!("/workspace/backend").as_ref(),
2965 path!("/workspace/shared").as_ref(),
2966 ],
2967 )
2968 .await;
2969
2970 let test_cases = vec![
2971 ("frontend/src/main.js", false, "File in first worktree"),
2972 ("backend/src/main.rs", false, "File in second worktree"),
2973 (
2974 "shared/.zed/settings.json",
2975 true,
2976 ".zed file in third worktree",
2977 ),
2978 ("/etc/hosts", true, "Absolute path outside all worktrees"),
2979 (
2980 "../outside/file.txt",
2981 true,
2982 "Relative path outside worktrees",
2983 ),
2984 ];
2985
2986 for (path, should_confirm, description) in test_cases {
2987 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2988 let auth =
2989 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2990
2991 if should_confirm {
2992 stream_rx.expect_authorization().await;
2993 } else {
2994 auth.await.unwrap();
2995 assert!(
2996 stream_rx.try_recv().is_err(),
2997 "Failed for case: {} - path: {} - expected no confirmation but got one",
2998 description,
2999 path
3000 );
3001 }
3002 }
3003 }
3004
3005 #[gpui::test]
3006 async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
3007 init_test(cx);
3008 let fs = project::FakeFs::new(cx.executor());
3009 fs.insert_tree(
3010 "/project",
3011 json!({
3012 ".zed": {
3013 "settings.json": "{}"
3014 },
3015 "src": {
3016 ".zed": {
3017 "local.json": "{}"
3018 }
3019 }
3020 }),
3021 )
3022 .await;
3023 let (tool, _project, _action_log, _fs, _thread) =
3024 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
3025
3026 let test_cases = vec![
3027 ("", false, "Empty path is treated as project root"),
3028 ("/", true, "Root directory should be outside project"),
3029 (
3030 "project/../other",
3031 true,
3032 "Path with .. that goes outside of root directory",
3033 ),
3034 (
3035 "project/./src/file.rs",
3036 false,
3037 "Path with . should work normally",
3038 ),
3039 #[cfg(target_os = "windows")]
3040 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
3041 #[cfg(target_os = "windows")]
3042 ("project\\src\\main.rs", false, "Windows-style project path"),
3043 ];
3044
3045 for (path, should_confirm, description) in test_cases {
3046 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3047 let auth =
3048 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
3049
3050 cx.run_until_parked();
3051
3052 if should_confirm {
3053 stream_rx.expect_authorization().await;
3054 } else {
3055 assert!(
3056 stream_rx.try_recv().is_err(),
3057 "Failed for case: {} - path: {} - expected no confirmation but got one",
3058 description,
3059 path
3060 );
3061 auth.await.unwrap();
3062 }
3063 }
3064 }
3065
3066 #[gpui::test]
3067 async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
3068 init_test(cx);
3069 let fs = project::FakeFs::new(cx.executor());
3070 fs.insert_tree(
3071 "/project",
3072 json!({
3073 "existing.txt": "content",
3074 ".zed": {
3075 "settings.json": "{}"
3076 }
3077 }),
3078 )
3079 .await;
3080 let (tool, _project, _action_log, _fs, _thread) =
3081 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
3082
3083 let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write];
3084
3085 for _mode in modes {
3086 // Test .zed path with different modes
3087 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3088 let _auth = cx.update(|cx| {
3089 tool.authorize(
3090 &PathBuf::from("project/.zed/settings.json"),
3091 "Edit settings",
3092 &stream_tx,
3093 cx,
3094 )
3095 });
3096
3097 stream_rx.expect_authorization().await;
3098
3099 // Test outside path with different modes
3100 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3101 let _auth = cx.update(|cx| {
3102 tool.authorize(
3103 &PathBuf::from("/outside/file.txt"),
3104 "Edit file",
3105 &stream_tx,
3106 cx,
3107 )
3108 });
3109
3110 stream_rx.expect_authorization().await;
3111
3112 // Test normal path with different modes
3113 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3114 cx.update(|cx| {
3115 tool.authorize(
3116 &PathBuf::from("project/normal.txt"),
3117 "Edit file",
3118 &stream_tx,
3119 cx,
3120 )
3121 })
3122 .await
3123 .unwrap();
3124 assert!(stream_rx.try_recv().is_err());
3125 }
3126 }
3127
3128 #[gpui::test]
3129 async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) {
3130 init_test(cx);
3131 let fs = project::FakeFs::new(cx.executor());
3132 fs.insert_tree("/project", json!({})).await;
3133 let (tool, _project, _action_log, _fs, _thread) =
3134 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
3135
3136 cx.update(|cx| {
3137 assert_eq!(
3138 tool.initial_title(
3139 Err(json!({
3140 "path": "src/main.rs",
3141 "display_description": "",
3142 })),
3143 cx
3144 ),
3145 "src/main.rs"
3146 );
3147 assert_eq!(
3148 tool.initial_title(
3149 Err(json!({
3150 "path": "",
3151 "display_description": "Fix error handling",
3152 })),
3153 cx
3154 ),
3155 "Fix error handling"
3156 );
3157 assert_eq!(
3158 tool.initial_title(
3159 Err(json!({
3160 "path": "src/main.rs",
3161 "display_description": "Fix error handling",
3162 })),
3163 cx
3164 ),
3165 "src/main.rs"
3166 );
3167 assert_eq!(
3168 tool.initial_title(
3169 Err(json!({
3170 "path": "",
3171 "display_description": "",
3172 })),
3173 cx
3174 ),
3175 DEFAULT_UI_TEXT
3176 );
3177 assert_eq!(
3178 tool.initial_title(Err(serde_json::Value::Null), cx),
3179 DEFAULT_UI_TEXT
3180 );
3181 });
3182 }
3183
3184 #[gpui::test]
3185 async fn test_streaming_diff_finalization(cx: &mut TestAppContext) {
3186 init_test(cx);
3187 let fs = project::FakeFs::new(cx.executor());
3188 fs.insert_tree("/", json!({"main.rs": ""})).await;
3189 let (tool, project, action_log, _fs, thread) =
3190 setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await;
3191 let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
3192
3193 // Ensure the diff is finalized after the edit completes.
3194 {
3195 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3196 let edit = cx.update(|cx| {
3197 tool.clone().run(
3198 ToolInput::resolved(StreamingEditFileToolInput {
3199 display_description: "Edit file".into(),
3200 path: path!("/main.rs").into(),
3201 mode: StreamingEditFileMode::Write,
3202 content: Some("new content".into()),
3203 edits: None,
3204 }),
3205 stream_tx,
3206 cx,
3207 )
3208 });
3209 stream_rx.expect_update_fields().await;
3210 let diff = stream_rx.expect_diff().await;
3211 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3212 cx.run_until_parked();
3213 edit.await.unwrap();
3214 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3215 }
3216
3217 // Ensure the diff is finalized if the tool call gets dropped.
3218 {
3219 let tool = Arc::new(StreamingEditFileTool::new(
3220 project.clone(),
3221 thread.downgrade(),
3222 action_log,
3223 language_registry,
3224 ));
3225 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3226 let edit = cx.update(|cx| {
3227 tool.run(
3228 ToolInput::resolved(StreamingEditFileToolInput {
3229 display_description: "Edit file".into(),
3230 path: path!("/main.rs").into(),
3231 mode: StreamingEditFileMode::Write,
3232 content: Some("dropped content".into()),
3233 edits: None,
3234 }),
3235 stream_tx,
3236 cx,
3237 )
3238 });
3239 stream_rx.expect_update_fields().await;
3240 let diff = stream_rx.expect_diff().await;
3241 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3242 drop(edit);
3243 cx.run_until_parked();
3244 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3245 }
3246 }
3247
3248 #[gpui::test]
3249 async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) {
3250 let (tool, project, action_log, _fs, _thread) =
3251 setup_test(cx, json!({"test.txt": "original content"})).await;
3252 let read_tool = Arc::new(crate::ReadFileTool::new(
3253 project.clone(),
3254 action_log.clone(),
3255 true,
3256 ));
3257
3258 // Read the file first
3259 cx.update(|cx| {
3260 read_tool.clone().run(
3261 ToolInput::resolved(crate::ReadFileToolInput {
3262 path: "root/test.txt".to_string(),
3263 start_line: None,
3264 end_line: None,
3265 }),
3266 ToolCallEventStream::test().0,
3267 cx,
3268 )
3269 })
3270 .await
3271 .unwrap();
3272
3273 // First edit should work
3274 let edit_result = cx
3275 .update(|cx| {
3276 tool.clone().run(
3277 ToolInput::resolved(StreamingEditFileToolInput {
3278 display_description: "First edit".into(),
3279 path: "root/test.txt".into(),
3280 mode: StreamingEditFileMode::Edit,
3281 content: None,
3282 edits: Some(vec![Edit {
3283 old_text: "original content".into(),
3284 new_text: "modified content".into(),
3285 }]),
3286 }),
3287 ToolCallEventStream::test().0,
3288 cx,
3289 )
3290 })
3291 .await;
3292 assert!(
3293 edit_result.is_ok(),
3294 "First edit should succeed, got error: {:?}",
3295 edit_result.as_ref().err()
3296 );
3297
3298 // Second edit should also work because the edit updated the recorded read time
3299 let edit_result = cx
3300 .update(|cx| {
3301 tool.clone().run(
3302 ToolInput::resolved(StreamingEditFileToolInput {
3303 display_description: "Second edit".into(),
3304 path: "root/test.txt".into(),
3305 mode: StreamingEditFileMode::Edit,
3306 content: None,
3307 edits: Some(vec![Edit {
3308 old_text: "modified content".into(),
3309 new_text: "further modified content".into(),
3310 }]),
3311 }),
3312 ToolCallEventStream::test().0,
3313 cx,
3314 )
3315 })
3316 .await;
3317 assert!(
3318 edit_result.is_ok(),
3319 "Second consecutive edit should succeed, got error: {:?}",
3320 edit_result.as_ref().err()
3321 );
3322 }
3323
3324 #[gpui::test]
3325 async fn test_streaming_external_modification_detected(cx: &mut TestAppContext) {
3326 let (tool, project, action_log, fs, _thread) =
3327 setup_test(cx, json!({"test.txt": "original content"})).await;
3328 let read_tool = Arc::new(crate::ReadFileTool::new(
3329 project.clone(),
3330 action_log.clone(),
3331 true,
3332 ));
3333
3334 // Read the file first
3335 cx.update(|cx| {
3336 read_tool.clone().run(
3337 ToolInput::resolved(crate::ReadFileToolInput {
3338 path: "root/test.txt".to_string(),
3339 start_line: None,
3340 end_line: None,
3341 }),
3342 ToolCallEventStream::test().0,
3343 cx,
3344 )
3345 })
3346 .await
3347 .unwrap();
3348
3349 // Simulate external modification
3350 cx.background_executor
3351 .advance_clock(std::time::Duration::from_secs(2));
3352 fs.save(
3353 path!("/root/test.txt").as_ref(),
3354 &"externally modified content".into(),
3355 language::LineEnding::Unix,
3356 )
3357 .await
3358 .unwrap();
3359
3360 // Reload the buffer to pick up the new mtime
3361 let project_path = project
3362 .read_with(cx, |project, cx| {
3363 project.find_project_path("root/test.txt", cx)
3364 })
3365 .expect("Should find project path");
3366 let buffer = project
3367 .update(cx, |project, cx| project.open_buffer(project_path, cx))
3368 .await
3369 .unwrap();
3370 buffer
3371 .update(cx, |buffer, cx| buffer.reload(cx))
3372 .await
3373 .unwrap();
3374
3375 cx.executor().run_until_parked();
3376
3377 // Try to edit - should fail because file was modified externally
3378 let result = cx
3379 .update(|cx| {
3380 tool.clone().run(
3381 ToolInput::resolved(StreamingEditFileToolInput {
3382 display_description: "Edit after external change".into(),
3383 path: "root/test.txt".into(),
3384 mode: StreamingEditFileMode::Edit,
3385 content: None,
3386 edits: Some(vec![Edit {
3387 old_text: "externally modified content".into(),
3388 new_text: "new content".into(),
3389 }]),
3390 }),
3391 ToolCallEventStream::test().0,
3392 cx,
3393 )
3394 })
3395 .await;
3396
3397 let StreamingEditFileToolOutput::Error {
3398 error,
3399 diff,
3400 input_path,
3401 } = result.unwrap_err()
3402 else {
3403 panic!("expected error");
3404 };
3405
3406 assert!(
3407 error.contains("has been modified since you last read it"),
3408 "Error should mention file modification, got: {}",
3409 error
3410 );
3411 assert!(diff.is_empty());
3412 assert!(input_path.is_none());
3413 }
3414
3415 #[gpui::test]
3416 async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) {
3417 let (tool, project, action_log, _fs, _thread) =
3418 setup_test(cx, json!({"test.txt": "original content"})).await;
3419 let read_tool = Arc::new(crate::ReadFileTool::new(
3420 project.clone(),
3421 action_log.clone(),
3422 true,
3423 ));
3424
3425 // Read the file first
3426 cx.update(|cx| {
3427 read_tool.clone().run(
3428 ToolInput::resolved(crate::ReadFileToolInput {
3429 path: "root/test.txt".to_string(),
3430 start_line: None,
3431 end_line: None,
3432 }),
3433 ToolCallEventStream::test().0,
3434 cx,
3435 )
3436 })
3437 .await
3438 .unwrap();
3439
3440 // Open the buffer and make it dirty
3441 let project_path = project
3442 .read_with(cx, |project, cx| {
3443 project.find_project_path("root/test.txt", cx)
3444 })
3445 .expect("Should find project path");
3446 let buffer = project
3447 .update(cx, |project, cx| project.open_buffer(project_path, cx))
3448 .await
3449 .unwrap();
3450
3451 buffer.update(cx, |buffer, cx| {
3452 let end_point = buffer.max_point();
3453 buffer.edit([(end_point..end_point, " added text")], None, cx);
3454 });
3455
3456 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
3457 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
3458
3459 // Try to edit - should fail because buffer has unsaved changes
3460 let result = cx
3461 .update(|cx| {
3462 tool.clone().run(
3463 ToolInput::resolved(StreamingEditFileToolInput {
3464 display_description: "Edit with dirty buffer".into(),
3465 path: "root/test.txt".into(),
3466 mode: StreamingEditFileMode::Edit,
3467 content: None,
3468 edits: Some(vec![Edit {
3469 old_text: "original content".into(),
3470 new_text: "new content".into(),
3471 }]),
3472 }),
3473 ToolCallEventStream::test().0,
3474 cx,
3475 )
3476 })
3477 .await;
3478
3479 let StreamingEditFileToolOutput::Error {
3480 error,
3481 diff,
3482 input_path,
3483 } = result.unwrap_err()
3484 else {
3485 panic!("expected error");
3486 };
3487 assert!(
3488 error.contains("This file has unsaved changes."),
3489 "Error should mention unsaved changes, got: {}",
3490 error
3491 );
3492 assert!(
3493 error.contains("keep or discard"),
3494 "Error should ask whether to keep or discard changes, got: {}",
3495 error
3496 );
3497 assert!(
3498 error.contains("save or revert the file manually"),
3499 "Error should ask user to manually save or revert when tools aren't available, got: {}",
3500 error
3501 );
3502 assert!(diff.is_empty());
3503 assert!(input_path.is_none());
3504 }
3505
3506 #[gpui::test]
3507 async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) {
3508 // Edit 1's replacement introduces text that contains edit 2's
3509 // old_text as a substring. Because edits resolve sequentially
3510 // against the current buffer, edit 2 finds a unique match in
3511 // the modified buffer and succeeds.
3512 let (tool, _project, _action_log, _fs, _thread) =
3513 setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
3514 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3515 let (event_stream, _receiver) = ToolCallEventStream::test();
3516 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3517
3518 // Setup: resolve the buffer
3519 sender.send_partial(json!({
3520 "display_description": "Overlapping edits",
3521 "path": "root/file.txt",
3522 "mode": "edit"
3523 }));
3524 cx.run_until_parked();
3525
3526 // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
3527 // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
3528 // Edit 2's old_text "ccc\nddd" matches the first occurrence
3529 // in the modified buffer and replaces it with "ZZZ".
3530 // Edit 3 exists only to mark edit 2 as "complete" during streaming.
3531 sender.send_partial(json!({
3532 "display_description": "Overlapping edits",
3533 "path": "root/file.txt",
3534 "mode": "edit",
3535 "edits": [
3536 {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3537 {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3538 {"old_text": "eee", "new_text": "DUMMY"}
3539 ]
3540 }));
3541 cx.run_until_parked();
3542
3543 // Send the final input with all three edits.
3544 sender.send_full(json!({
3545 "display_description": "Overlapping edits",
3546 "path": "root/file.txt",
3547 "mode": "edit",
3548 "edits": [
3549 {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3550 {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3551 {"old_text": "eee", "new_text": "DUMMY"}
3552 ]
3553 }));
3554
3555 let result = task.await;
3556 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3557 panic!("expected success");
3558 };
3559 assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
3560 }
3561
3562 #[gpui::test]
3563 async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) {
3564 let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
3565 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3566 let (event_stream, _receiver) = ToolCallEventStream::test();
3567 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3568
3569 // Transition to BufferResolved
3570 sender.send_partial(json!({
3571 "display_description": "Create new file",
3572 "path": "root/dir/new_file.txt",
3573 "mode": "write"
3574 }));
3575 cx.run_until_parked();
3576
3577 // Stream content incrementally
3578 sender.send_partial(json!({
3579 "display_description": "Create new file",
3580 "path": "root/dir/new_file.txt",
3581 "mode": "write",
3582 "content": "line 1\n"
3583 }));
3584 cx.run_until_parked();
3585
3586 // Verify buffer has partial content
3587 let buffer = project.update(cx, |project, cx| {
3588 let path = project
3589 .find_project_path("root/dir/new_file.txt", cx)
3590 .unwrap();
3591 project.get_open_buffer(&path, cx).unwrap()
3592 });
3593 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n");
3594
3595 // Stream more content
3596 sender.send_partial(json!({
3597 "display_description": "Create new file",
3598 "path": "root/dir/new_file.txt",
3599 "mode": "write",
3600 "content": "line 1\nline 2\n"
3601 }));
3602 cx.run_until_parked();
3603 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n");
3604
3605 // Stream final chunk
3606 sender.send_partial(json!({
3607 "display_description": "Create new file",
3608 "path": "root/dir/new_file.txt",
3609 "mode": "write",
3610 "content": "line 1\nline 2\nline 3\n"
3611 }));
3612 cx.run_until_parked();
3613 assert_eq!(
3614 buffer.read_with(cx, |b, _| b.text()),
3615 "line 1\nline 2\nline 3\n"
3616 );
3617
3618 // Send final input
3619 sender.send_full(json!({
3620 "display_description": "Create new file",
3621 "path": "root/dir/new_file.txt",
3622 "mode": "write",
3623 "content": "line 1\nline 2\nline 3\n"
3624 }));
3625
3626 let result = task.await;
3627 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3628 panic!("expected success");
3629 };
3630 assert_eq!(new_text, "line 1\nline 2\nline 3\n");
3631 }
3632
3633 #[gpui::test]
3634 async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) {
3635 let (tool, _project, _action_log, _fs, _thread) = setup_test(
3636 cx,
3637 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3638 )
3639 .await;
3640 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3641 let (event_stream, mut receiver) = ToolCallEventStream::test();
3642 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3643
3644 // Transition to BufferResolved
3645 sender.send_partial(json!({
3646 "display_description": "Overwrite file",
3647 "path": "root/file.txt",
3648 }));
3649 cx.run_until_parked();
3650
3651 sender.send_partial(json!({
3652 "display_description": "Overwrite file",
3653 "path": "root/file.txt",
3654 "mode": "write"
3655 }));
3656 cx.run_until_parked();
3657
3658 // Get the diff entity from the event stream
3659 receiver.expect_update_fields().await;
3660 let diff = receiver.expect_diff().await;
3661
3662 // Diff starts pending with no revealed ranges
3663 diff.read_with(cx, |diff, cx| {
3664 assert!(matches!(diff, Diff::Pending(_)));
3665 assert!(!diff.has_revealed_range(cx));
3666 });
3667
3668 // Stream first content chunk
3669 sender.send_partial(json!({
3670 "display_description": "Overwrite file",
3671 "path": "root/file.txt",
3672 "mode": "write",
3673 "content": "new line 1\n"
3674 }));
3675 cx.run_until_parked();
3676
3677 // Diff should now have revealed ranges showing the new content
3678 diff.read_with(cx, |diff, cx| {
3679 assert!(diff.has_revealed_range(cx));
3680 });
3681
3682 // Send final input
3683 sender.send_full(json!({
3684 "display_description": "Overwrite file",
3685 "path": "root/file.txt",
3686 "mode": "write",
3687 "content": "new line 1\nnew line 2\n"
3688 }));
3689
3690 let result = task.await;
3691 let StreamingEditFileToolOutput::Success {
3692 new_text, old_text, ..
3693 } = result.unwrap()
3694 else {
3695 panic!("expected success");
3696 };
3697 assert_eq!(new_text, "new line 1\nnew line 2\n");
3698 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3699
3700 // Diff is finalized after completion
3701 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3702 }
3703
3704 #[gpui::test]
3705 async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) {
3706 let (tool, project, _action_log, _fs, _thread) = setup_test(
3707 cx,
3708 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3709 )
3710 .await;
3711 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3712 let (event_stream, _receiver) = ToolCallEventStream::test();
3713 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3714
3715 // Transition to BufferResolved
3716 sender.send_partial(json!({
3717 "display_description": "Overwrite file",
3718 "path": "root/file.txt",
3719 "mode": "write"
3720 }));
3721 cx.run_until_parked();
3722
3723 // Verify buffer still has old content (no content partial yet)
3724 let buffer = project.update(cx, |project, cx| {
3725 let path = project.find_project_path("root/file.txt", cx).unwrap();
3726 project.open_buffer(path, cx)
3727 });
3728 let buffer = buffer.await.unwrap();
3729 assert_eq!(
3730 buffer.read_with(cx, |b, _| b.text()),
3731 "old line 1\nold line 2\nold line 3\n"
3732 );
3733
3734 // First content partial replaces old content
3735 sender.send_partial(json!({
3736 "display_description": "Overwrite file",
3737 "path": "root/file.txt",
3738 "mode": "write",
3739 "content": "new line 1\n"
3740 }));
3741 cx.run_until_parked();
3742 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n");
3743
3744 // Subsequent content partials append
3745 sender.send_partial(json!({
3746 "display_description": "Overwrite file",
3747 "path": "root/file.txt",
3748 "mode": "write",
3749 "content": "new line 1\nnew line 2\n"
3750 }));
3751 cx.run_until_parked();
3752 assert_eq!(
3753 buffer.read_with(cx, |b, _| b.text()),
3754 "new line 1\nnew line 2\n"
3755 );
3756
3757 // Send final input with complete content
3758 sender.send_full(json!({
3759 "display_description": "Overwrite file",
3760 "path": "root/file.txt",
3761 "mode": "write",
3762 "content": "new line 1\nnew line 2\nnew line 3\n"
3763 }));
3764
3765 let result = task.await;
3766 let StreamingEditFileToolOutput::Success {
3767 new_text, old_text, ..
3768 } = result.unwrap()
3769 else {
3770 panic!("expected success");
3771 };
3772 assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n");
3773 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3774 }
3775
3776 #[gpui::test]
3777 async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) {
3778 let (tool, _project, _action_log, _fs, _thread) =
3779 setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await;
3780 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3781 let (event_stream, _receiver) = ToolCallEventStream::test();
3782 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3783
3784 sender.send_partial(json!({
3785 "display_description": "Edit",
3786 "path": "root/file.txt",
3787 "mode": "edit"
3788 }));
3789 cx.run_until_parked();
3790
3791 // Simulate JSON fixer producing a literal backslash when the LLM
3792 // stream cuts in the middle of a \n escape sequence.
3793 // The old_text "hello\nworld" would be streamed as:
3794 // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\)
3795 // partial 2: old_text = "hello\nworld" (fixer corrected the escape)
3796 sender.send_partial(json!({
3797 "display_description": "Edit",
3798 "path": "root/file.txt",
3799 "mode": "edit",
3800 "edits": [{"old_text": "hello\\"}]
3801 }));
3802 cx.run_until_parked();
3803
3804 // Now the fixer corrects it to the real newline.
3805 sender.send_partial(json!({
3806 "display_description": "Edit",
3807 "path": "root/file.txt",
3808 "mode": "edit",
3809 "edits": [{"old_text": "hello\nworld"}]
3810 }));
3811 cx.run_until_parked();
3812
3813 // Send final.
3814 sender.send_full(json!({
3815 "display_description": "Edit",
3816 "path": "root/file.txt",
3817 "mode": "edit",
3818 "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}]
3819 }));
3820
3821 let result = task.await;
3822 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3823 panic!("expected success");
3824 };
3825 assert_eq!(new_text, "HELLO\nWORLD\nfoo\n");
3826 }
3827
3828 #[gpui::test]
3829 async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) {
3830 let (tool, _project, _action_log, _fs, _thread) =
3831 setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await;
3832 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3833 let (event_stream, _receiver) = ToolCallEventStream::test();
3834 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3835
3836 sender.send_partial(json!({
3837 "display_description": "Edit",
3838 "path": "root/file.txt",
3839 "mode": "edit"
3840 }));
3841 cx.run_until_parked();
3842
3843 sender.send_full(json!({
3844 "display_description": "Edit",
3845 "path": "root/file.txt",
3846 "mode": "edit",
3847 "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]"
3848 }));
3849
3850 let result = task.await;
3851 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3852 panic!("expected success");
3853 };
3854 assert_eq!(new_text, "HELLO\nWORLD\n");
3855 }
3856
3857 // Verifies that after streaming_edit_file_tool edits a file, the action log
3858 // reports changed buffers so that the Accept All / Reject All review UI appears.
3859 #[gpui::test]
3860 async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) {
3861 let (tool, _project, action_log, _fs, _thread) =
3862 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
3863 cx.update(|cx| {
3864 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3865 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3866 agent_settings::AgentSettings::override_global(settings, cx);
3867 });
3868
3869 let (event_stream, _rx) = ToolCallEventStream::test();
3870 let task = cx.update(|cx| {
3871 tool.clone().run(
3872 ToolInput::resolved(StreamingEditFileToolInput {
3873 display_description: "Edit lines".to_string(),
3874 path: "root/file.txt".into(),
3875 mode: StreamingEditFileMode::Edit,
3876 content: None,
3877 edits: Some(vec![Edit {
3878 old_text: "line 2".into(),
3879 new_text: "modified line 2".into(),
3880 }]),
3881 }),
3882 event_stream,
3883 cx,
3884 )
3885 });
3886
3887 let result = task.await;
3888 assert!(result.is_ok(), "edit should succeed: {:?}", result.err());
3889
3890 cx.run_until_parked();
3891
3892 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3893 assert!(
3894 !changed.is_empty(),
3895 "action_log.changed_buffers() should be non-empty after streaming edit,
3896 but no changed buffers were found - Accept All / Reject All will not appear"
3897 );
3898 }
3899
3900 // Same test but for Write mode (overwrite entire file).
3901 #[gpui::test]
3902 async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers(
3903 cx: &mut TestAppContext,
3904 ) {
3905 let (tool, _project, action_log, _fs, _thread) =
3906 setup_test(cx, json!({"file.txt": "original content"})).await;
3907 cx.update(|cx| {
3908 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3909 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3910 agent_settings::AgentSettings::override_global(settings, cx);
3911 });
3912
3913 let (event_stream, _rx) = ToolCallEventStream::test();
3914 let task = cx.update(|cx| {
3915 tool.clone().run(
3916 ToolInput::resolved(StreamingEditFileToolInput {
3917 display_description: "Overwrite file".to_string(),
3918 path: "root/file.txt".into(),
3919 mode: StreamingEditFileMode::Write,
3920 content: Some("completely new content".into()),
3921 edits: None,
3922 }),
3923 event_stream,
3924 cx,
3925 )
3926 });
3927
3928 let result = task.await;
3929 assert!(result.is_ok(), "write should succeed: {:?}", result.err());
3930
3931 cx.run_until_parked();
3932
3933 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3934 assert!(
3935 !changed.is_empty(),
3936 "action_log.changed_buffers() should be non-empty after streaming write, \
3937 but no changed buffers were found \u{2014} Accept All / Reject All will not appear"
3938 );
3939 }
3940
3941 #[gpui::test]
3942 async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
3943 cx: &mut TestAppContext,
3944 ) {
3945 let (tool, _project, _action_log, _fs, _thread) =
3946 setup_test(cx, json!({"file.txt": "old_content"})).await;
3947 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3948 let (event_stream, _receiver) = ToolCallEventStream::test();
3949 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3950
3951 sender.send_partial(json!({
3952 "display_description": "Overwrite file",
3953 "mode": "write"
3954 }));
3955 cx.run_until_parked();
3956
3957 sender.send_partial(json!({
3958 "display_description": "Overwrite file",
3959 "mode": "write",
3960 "content": "new_content"
3961 }));
3962 cx.run_until_parked();
3963
3964 sender.send_partial(json!({
3965 "display_description": "Overwrite file",
3966 "mode": "write",
3967 "content": "new_content",
3968 "path": "root"
3969 }));
3970 cx.run_until_parked();
3971
3972 // Send final.
3973 sender.send_full(json!({
3974 "display_description": "Overwrite file",
3975 "mode": "write",
3976 "content": "new_content",
3977 "path": "root/file.txt"
3978 }));
3979
3980 let result = task.await;
3981 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3982 panic!("expected success");
3983 };
3984 assert_eq!(new_text, "new_content");
3985 }
3986
3987 #[gpui::test]
3988 async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
3989 cx: &mut TestAppContext,
3990 ) {
3991 let (tool, _project, _action_log, _fs, _thread) =
3992 setup_test(cx, json!({"file.txt": "old_content"})).await;
3993 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3994 let (event_stream, _receiver) = ToolCallEventStream::test();
3995 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3996
3997 sender.send_partial(json!({
3998 "display_description": "Overwrite file",
3999 "mode": "edit"
4000 }));
4001 cx.run_until_parked();
4002
4003 sender.send_partial(json!({
4004 "display_description": "Overwrite file",
4005 "mode": "edit",
4006 "edits": [{"old_text": "old_content"}]
4007 }));
4008 cx.run_until_parked();
4009
4010 sender.send_partial(json!({
4011 "display_description": "Overwrite file",
4012 "mode": "edit",
4013 "edits": [{"old_text": "old_content", "new_text": "new_content"}]
4014 }));
4015 cx.run_until_parked();
4016
4017 sender.send_partial(json!({
4018 "display_description": "Overwrite file",
4019 "mode": "edit",
4020 "edits": [{"old_text": "old_content", "new_text": "new_content"}],
4021 "path": "root"
4022 }));
4023 cx.run_until_parked();
4024
4025 // Send final.
4026 sender.send_full(json!({
4027 "display_description": "Overwrite file",
4028 "mode": "edit",
4029 "edits": [{"old_text": "old_content", "new_text": "new_content"}],
4030 "path": "root/file.txt"
4031 }));
4032 cx.run_until_parked();
4033
4034 let result = task.await;
4035 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
4036 panic!("expected success");
4037 };
4038 assert_eq!(new_text, "new_content");
4039 }
4040
4041 #[gpui::test]
4042 async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) {
4043 let file_content = indoc::indoc! {r#"
4044 fn on_query_change(&mut self, cx: &mut Context<Self>) {
4045 self.filter(cx);
4046 }
4047
4048
4049
4050 fn render_search(&self, cx: &mut Context<Self>) -> Div {
4051 div()
4052 }
4053 "#}
4054 .to_string();
4055
4056 let (tool, _project, _action_log, _fs, _thread) =
4057 setup_test(cx, json!({"file.rs": file_content})).await;
4058
4059 // The model sends old_text with a PARTIAL last line.
4060 let old_text = "}\n\n\n\nfn render_search";
4061 let new_text = "}\n\nfn render_search";
4062
4063 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
4064 let (event_stream, _receiver) = ToolCallEventStream::test();
4065 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
4066
4067 sender.send_full(json!({
4068 "display_description": "Remove extra blank lines",
4069 "path": "root/file.rs",
4070 "mode": "edit",
4071 "edits": [{"old_text": old_text, "new_text": new_text}]
4072 }));
4073
4074 let result = task.await;
4075 let StreamingEditFileToolOutput::Success {
4076 new_text: final_text,
4077 ..
4078 } = result.unwrap()
4079 else {
4080 panic!("expected success");
4081 };
4082
4083 // The edit should reduce 3 blank lines to 1 blank line before
4084 // fn render_search, without duplicating the function signature.
4085 let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search");
4086 pretty_assertions::assert_eq!(
4087 final_text,
4088 expected,
4089 "Edit should only remove blank lines before render_search"
4090 );
4091 }
4092
4093 #[gpui::test]
4094 async fn test_streaming_edit_preserves_blank_line_after_trailing_newline_replacement(
4095 cx: &mut TestAppContext,
4096 ) {
4097 let file_content = "before\ntarget\n\nafter\n";
4098 let old_text = "target\n";
4099 let new_text = "one\ntwo\ntarget\n";
4100 let expected = "before\none\ntwo\ntarget\n\nafter\n";
4101
4102 let (tool, _project, _action_log, _fs, _thread) =
4103 setup_test(cx, json!({"file.rs": file_content})).await;
4104 let (mut sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
4105 let (event_stream, _receiver) = ToolCallEventStream::test();
4106 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
4107
4108 sender.send_full(json!({
4109 "display_description": "description",
4110 "path": "root/file.rs",
4111 "mode": "edit",
4112 "edits": [{"old_text": old_text, "new_text": new_text}]
4113 }));
4114
4115 let result = task.await;
4116
4117 let StreamingEditFileToolOutput::Success {
4118 new_text: final_text,
4119 ..
4120 } = result.unwrap()
4121 else {
4122 panic!("expected success");
4123 };
4124
4125 pretty_assertions::assert_eq!(
4126 final_text,
4127 expected,
4128 "Edit should preserve a single blank line before test_after"
4129 );
4130 }
4131
4132 #[gpui::test]
4133 async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) {
4134 let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
4135 cx.update(|cx| {
4136 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
4137 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
4138 agent_settings::AgentSettings::override_global(settings, cx);
4139 });
4140
4141 // Create a new file via the streaming edit file tool
4142 let (event_stream, _rx) = ToolCallEventStream::test();
4143 let task = cx.update(|cx| {
4144 tool.clone().run(
4145 ToolInput::resolved(StreamingEditFileToolInput {
4146 display_description: "Create new file".into(),
4147 path: "root/dir/new_file.txt".into(),
4148 mode: StreamingEditFileMode::Write,
4149 content: Some("Hello, World!".into()),
4150 edits: None,
4151 }),
4152 event_stream,
4153 cx,
4154 )
4155 });
4156 let result = task.await;
4157 assert!(result.is_ok(), "create should succeed: {:?}", result.err());
4158 cx.run_until_parked();
4159
4160 assert!(
4161 fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
4162 "file should exist after creation"
4163 );
4164
4165 // Reject all edits — this should delete the newly created file
4166 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
4167 assert!(
4168 !changed.is_empty(),
4169 "action_log should track the created file as changed"
4170 );
4171
4172 action_log
4173 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4174 .await;
4175 cx.run_until_parked();
4176
4177 assert!(
4178 !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
4179 "file should be deleted after rejecting creation, but an empty file was left behind"
4180 );
4181 }
4182
4183 async fn setup_test_with_fs(
4184 cx: &mut TestAppContext,
4185 fs: Arc<project::FakeFs>,
4186 worktree_paths: &[&std::path::Path],
4187 ) -> (
4188 Arc<StreamingEditFileTool>,
4189 Entity<Project>,
4190 Entity<ActionLog>,
4191 Arc<project::FakeFs>,
4192 Entity<Thread>,
4193 ) {
4194 let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await;
4195 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
4196 let context_server_registry =
4197 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4198 let model = Arc::new(FakeLanguageModel::default());
4199 let thread = cx.new(|cx| {
4200 crate::Thread::new(
4201 project.clone(),
4202 cx.new(|_cx| ProjectContext::default()),
4203 context_server_registry,
4204 Templates::new(),
4205 Some(model),
4206 cx,
4207 )
4208 });
4209 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
4210 let tool = Arc::new(StreamingEditFileTool::new(
4211 project.clone(),
4212 thread.downgrade(),
4213 action_log.clone(),
4214 language_registry,
4215 ));
4216 (tool, project, action_log, fs, thread)
4217 }
4218
4219 async fn setup_test(
4220 cx: &mut TestAppContext,
4221 initial_tree: serde_json::Value,
4222 ) -> (
4223 Arc<StreamingEditFileTool>,
4224 Entity<Project>,
4225 Entity<ActionLog>,
4226 Arc<project::FakeFs>,
4227 Entity<Thread>,
4228 ) {
4229 init_test(cx);
4230 let fs = project::FakeFs::new(cx.executor());
4231 fs.insert_tree("/root", initial_tree).await;
4232 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await
4233 }
4234
4235 fn init_test(cx: &mut TestAppContext) {
4236 cx.update(|cx| {
4237 let settings_store = SettingsStore::test(cx);
4238 cx.set_global(settings_store);
4239 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
4240 store.update_user_settings(cx, |settings| {
4241 settings
4242 .project
4243 .all_languages
4244 .defaults
4245 .ensure_final_newline_on_save = Some(false);
4246 });
4247 });
4248 });
4249 }
4250}