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