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