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