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