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