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