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