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