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