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