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