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