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::PermissionOptionId::new("allow"))
2585 .unwrap();
2586 authorize_task.await.unwrap();
2587 }
2588
2589 #[gpui::test]
2590 async fn test_streaming_edit_file_symlink_escape_requests_authorization(
2591 cx: &mut TestAppContext,
2592 ) {
2593 init_test(cx);
2594
2595 let fs = project::FakeFs::new(cx.executor());
2596 fs.insert_tree(
2597 path!("/root"),
2598 json!({
2599 "src": { "main.rs": "fn main() {}" }
2600 }),
2601 )
2602 .await;
2603 fs.insert_tree(
2604 path!("/outside"),
2605 json!({
2606 "config.txt": "old content"
2607 }),
2608 )
2609 .await;
2610 fs.create_symlink(
2611 path!("/root/link_to_external").as_ref(),
2612 PathBuf::from("/outside"),
2613 )
2614 .await
2615 .unwrap();
2616 let (tool, _project, _action_log, _fs, _thread) =
2617 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2618
2619 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2620 let _authorize_task = cx.update(|cx| {
2621 tool.authorize(
2622 &PathBuf::from("link_to_external/config.txt"),
2623 "edit through symlink",
2624 &stream_tx,
2625 cx,
2626 )
2627 });
2628
2629 let auth = stream_rx.expect_authorization().await;
2630 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
2631 assert!(
2632 title.contains("points outside the project"),
2633 "title should mention symlink escape, got: {title}"
2634 );
2635 }
2636
2637 #[gpui::test]
2638 async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) {
2639 init_test(cx);
2640
2641 let fs = project::FakeFs::new(cx.executor());
2642 fs.insert_tree(
2643 path!("/root"),
2644 json!({
2645 "src": { "main.rs": "fn main() {}" }
2646 }),
2647 )
2648 .await;
2649 fs.insert_tree(
2650 path!("/outside"),
2651 json!({
2652 "config.txt": "old content"
2653 }),
2654 )
2655 .await;
2656 fs.create_symlink(
2657 path!("/root/link_to_external").as_ref(),
2658 PathBuf::from("/outside"),
2659 )
2660 .await
2661 .unwrap();
2662 let (tool, _project, _action_log, _fs, _thread) =
2663 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2664
2665 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2666 let authorize_task = cx.update(|cx| {
2667 tool.authorize(
2668 &PathBuf::from("link_to_external/config.txt"),
2669 "edit through symlink",
2670 &stream_tx,
2671 cx,
2672 )
2673 });
2674
2675 let auth = stream_rx.expect_authorization().await;
2676 drop(auth); // deny by dropping
2677
2678 let result = authorize_task.await;
2679 assert!(result.is_err(), "should fail when denied");
2680 }
2681
2682 #[gpui::test]
2683 async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
2684 init_test(cx);
2685 cx.update(|cx| {
2686 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2687 settings.tool_permissions.tools.insert(
2688 "edit_file".into(),
2689 agent_settings::ToolRules {
2690 default: Some(settings::ToolPermissionMode::Deny),
2691 ..Default::default()
2692 },
2693 );
2694 agent_settings::AgentSettings::override_global(settings, cx);
2695 });
2696
2697 let fs = project::FakeFs::new(cx.executor());
2698 fs.insert_tree(
2699 path!("/root"),
2700 json!({
2701 "src": { "main.rs": "fn main() {}" }
2702 }),
2703 )
2704 .await;
2705 fs.insert_tree(
2706 path!("/outside"),
2707 json!({
2708 "config.txt": "old content"
2709 }),
2710 )
2711 .await;
2712 fs.create_symlink(
2713 path!("/root/link_to_external").as_ref(),
2714 PathBuf::from("/outside"),
2715 )
2716 .await
2717 .unwrap();
2718 let (tool, _project, _action_log, _fs, _thread) =
2719 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
2720
2721 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2722 let result = cx
2723 .update(|cx| {
2724 tool.authorize(
2725 &PathBuf::from("link_to_external/config.txt"),
2726 "edit through symlink",
2727 &stream_tx,
2728 cx,
2729 )
2730 })
2731 .await;
2732
2733 assert!(result.is_err(), "Tool should fail when policy denies");
2734 assert!(
2735 !matches!(
2736 stream_rx.try_next(),
2737 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
2738 ),
2739 "Deny policy should not emit symlink authorization prompt",
2740 );
2741 }
2742
2743 #[gpui::test]
2744 async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) {
2745 init_test(cx);
2746 let fs = project::FakeFs::new(cx.executor());
2747 fs.insert_tree("/project", json!({})).await;
2748 let (tool, _project, _action_log, _fs, _thread) =
2749 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2750
2751 let test_cases = vec![
2752 (
2753 "/etc/hosts",
2754 true,
2755 "System file should require confirmation",
2756 ),
2757 (
2758 "/usr/local/bin/script",
2759 true,
2760 "System bin file should require confirmation",
2761 ),
2762 (
2763 "project/normal_file.rs",
2764 false,
2765 "Normal project file should not require confirmation",
2766 ),
2767 ];
2768
2769 for (path, should_confirm, description) in test_cases {
2770 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2771 let auth =
2772 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2773
2774 if should_confirm {
2775 stream_rx.expect_authorization().await;
2776 } else {
2777 auth.await.unwrap();
2778 assert!(
2779 stream_rx.try_next().is_err(),
2780 "Failed for case: {} - path: {} - expected no confirmation but got one",
2781 description,
2782 path
2783 );
2784 }
2785 }
2786 }
2787
2788 #[gpui::test]
2789 async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2790 init_test(cx);
2791 let fs = project::FakeFs::new(cx.executor());
2792 fs.insert_tree(
2793 "/workspace/frontend",
2794 json!({
2795 "src": {
2796 "main.js": "console.log('frontend');"
2797 }
2798 }),
2799 )
2800 .await;
2801 fs.insert_tree(
2802 "/workspace/backend",
2803 json!({
2804 "src": {
2805 "main.rs": "fn main() {}"
2806 }
2807 }),
2808 )
2809 .await;
2810 fs.insert_tree(
2811 "/workspace/shared",
2812 json!({
2813 ".zed": {
2814 "settings.json": "{}"
2815 }
2816 }),
2817 )
2818 .await;
2819 let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(
2820 cx,
2821 fs,
2822 &[
2823 path!("/workspace/frontend").as_ref(),
2824 path!("/workspace/backend").as_ref(),
2825 path!("/workspace/shared").as_ref(),
2826 ],
2827 )
2828 .await;
2829
2830 let test_cases = vec![
2831 ("frontend/src/main.js", false, "File in first worktree"),
2832 ("backend/src/main.rs", false, "File in second worktree"),
2833 (
2834 "shared/.zed/settings.json",
2835 true,
2836 ".zed file in third worktree",
2837 ),
2838 ("/etc/hosts", true, "Absolute path outside all worktrees"),
2839 (
2840 "../outside/file.txt",
2841 true,
2842 "Relative path outside worktrees",
2843 ),
2844 ];
2845
2846 for (path, should_confirm, description) in test_cases {
2847 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2848 let auth =
2849 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2850
2851 if should_confirm {
2852 stream_rx.expect_authorization().await;
2853 } else {
2854 auth.await.unwrap();
2855 assert!(
2856 stream_rx.try_next().is_err(),
2857 "Failed for case: {} - path: {} - expected no confirmation but got one",
2858 description,
2859 path
2860 );
2861 }
2862 }
2863 }
2864
2865 #[gpui::test]
2866 async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2867 init_test(cx);
2868 let fs = project::FakeFs::new(cx.executor());
2869 fs.insert_tree(
2870 "/project",
2871 json!({
2872 ".zed": {
2873 "settings.json": "{}"
2874 },
2875 "src": {
2876 ".zed": {
2877 "local.json": "{}"
2878 }
2879 }
2880 }),
2881 )
2882 .await;
2883 let (tool, _project, _action_log, _fs, _thread) =
2884 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2885
2886 let test_cases = vec![
2887 ("", false, "Empty path is treated as project root"),
2888 ("/", true, "Root directory should be outside project"),
2889 (
2890 "project/../other",
2891 true,
2892 "Path with .. that goes outside of root directory",
2893 ),
2894 (
2895 "project/./src/file.rs",
2896 false,
2897 "Path with . should work normally",
2898 ),
2899 #[cfg(target_os = "windows")]
2900 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2901 #[cfg(target_os = "windows")]
2902 ("project\\src\\main.rs", false, "Windows-style project path"),
2903 ];
2904
2905 for (path, should_confirm, description) in test_cases {
2906 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2907 let auth =
2908 cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx));
2909
2910 cx.run_until_parked();
2911
2912 if should_confirm {
2913 stream_rx.expect_authorization().await;
2914 } else {
2915 assert!(
2916 stream_rx.try_next().is_err(),
2917 "Failed for case: {} - path: {} - expected no confirmation but got one",
2918 description,
2919 path
2920 );
2921 auth.await.unwrap();
2922 }
2923 }
2924 }
2925
2926 #[gpui::test]
2927 async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2928 init_test(cx);
2929 let fs = project::FakeFs::new(cx.executor());
2930 fs.insert_tree(
2931 "/project",
2932 json!({
2933 "existing.txt": "content",
2934 ".zed": {
2935 "settings.json": "{}"
2936 }
2937 }),
2938 )
2939 .await;
2940 let (tool, _project, _action_log, _fs, _thread) =
2941 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2942
2943 let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write];
2944
2945 for _mode in modes {
2946 // Test .zed path with different modes
2947 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2948 let _auth = cx.update(|cx| {
2949 tool.authorize(
2950 &PathBuf::from("project/.zed/settings.json"),
2951 "Edit settings",
2952 &stream_tx,
2953 cx,
2954 )
2955 });
2956
2957 stream_rx.expect_authorization().await;
2958
2959 // Test outside path with different modes
2960 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2961 let _auth = cx.update(|cx| {
2962 tool.authorize(
2963 &PathBuf::from("/outside/file.txt"),
2964 "Edit file",
2965 &stream_tx,
2966 cx,
2967 )
2968 });
2969
2970 stream_rx.expect_authorization().await;
2971
2972 // Test normal path with different modes
2973 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
2974 cx.update(|cx| {
2975 tool.authorize(
2976 &PathBuf::from("project/normal.txt"),
2977 "Edit file",
2978 &stream_tx,
2979 cx,
2980 )
2981 })
2982 .await
2983 .unwrap();
2984 assert!(stream_rx.try_next().is_err());
2985 }
2986 }
2987
2988 #[gpui::test]
2989 async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) {
2990 init_test(cx);
2991 let fs = project::FakeFs::new(cx.executor());
2992 fs.insert_tree("/project", json!({})).await;
2993 let (tool, _project, _action_log, _fs, _thread) =
2994 setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await;
2995
2996 cx.update(|cx| {
2997 assert_eq!(
2998 tool.initial_title(
2999 Err(json!({
3000 "path": "src/main.rs",
3001 "display_description": "",
3002 })),
3003 cx
3004 ),
3005 "src/main.rs"
3006 );
3007 assert_eq!(
3008 tool.initial_title(
3009 Err(json!({
3010 "path": "",
3011 "display_description": "Fix error handling",
3012 })),
3013 cx
3014 ),
3015 "Fix error handling"
3016 );
3017 assert_eq!(
3018 tool.initial_title(
3019 Err(json!({
3020 "path": "src/main.rs",
3021 "display_description": "Fix error handling",
3022 })),
3023 cx
3024 ),
3025 "src/main.rs"
3026 );
3027 assert_eq!(
3028 tool.initial_title(
3029 Err(json!({
3030 "path": "",
3031 "display_description": "",
3032 })),
3033 cx
3034 ),
3035 DEFAULT_UI_TEXT
3036 );
3037 assert_eq!(
3038 tool.initial_title(Err(serde_json::Value::Null), cx),
3039 DEFAULT_UI_TEXT
3040 );
3041 });
3042 }
3043
3044 #[gpui::test]
3045 async fn test_streaming_diff_finalization(cx: &mut TestAppContext) {
3046 init_test(cx);
3047 let fs = project::FakeFs::new(cx.executor());
3048 fs.insert_tree("/", json!({"main.rs": ""})).await;
3049 let (tool, project, action_log, _fs, thread) =
3050 setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await;
3051 let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
3052
3053 // Ensure the diff is finalized after the edit completes.
3054 {
3055 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3056 let edit = cx.update(|cx| {
3057 tool.clone().run(
3058 ToolInput::resolved(StreamingEditFileToolInput {
3059 display_description: "Edit file".into(),
3060 path: path!("/main.rs").into(),
3061 mode: StreamingEditFileMode::Write,
3062 content: Some("new content".into()),
3063 edits: None,
3064 }),
3065 stream_tx,
3066 cx,
3067 )
3068 });
3069 stream_rx.expect_update_fields().await;
3070 let diff = stream_rx.expect_diff().await;
3071 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3072 cx.run_until_parked();
3073 edit.await.unwrap();
3074 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3075 }
3076
3077 // Ensure the diff is finalized if the tool call gets dropped.
3078 {
3079 let tool = Arc::new(StreamingEditFileTool::new(
3080 project.clone(),
3081 thread.downgrade(),
3082 action_log,
3083 language_registry,
3084 ));
3085 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
3086 let edit = cx.update(|cx| {
3087 tool.run(
3088 ToolInput::resolved(StreamingEditFileToolInput {
3089 display_description: "Edit file".into(),
3090 path: path!("/main.rs").into(),
3091 mode: StreamingEditFileMode::Write,
3092 content: Some("dropped content".into()),
3093 edits: None,
3094 }),
3095 stream_tx,
3096 cx,
3097 )
3098 });
3099 stream_rx.expect_update_fields().await;
3100 let diff = stream_rx.expect_diff().await;
3101 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
3102 drop(edit);
3103 cx.run_until_parked();
3104 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3105 }
3106 }
3107
3108 #[gpui::test]
3109 async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) {
3110 let (tool, project, action_log, _fs, _thread) =
3111 setup_test(cx, json!({"test.txt": "original content"})).await;
3112 let read_tool = Arc::new(crate::ReadFileTool::new(
3113 project.clone(),
3114 action_log.clone(),
3115 true,
3116 ));
3117
3118 // Read the file first
3119 cx.update(|cx| {
3120 read_tool.clone().run(
3121 ToolInput::resolved(crate::ReadFileToolInput {
3122 path: "root/test.txt".to_string(),
3123 start_line: None,
3124 end_line: None,
3125 }),
3126 ToolCallEventStream::test().0,
3127 cx,
3128 )
3129 })
3130 .await
3131 .unwrap();
3132
3133 // First edit should work
3134 let edit_result = cx
3135 .update(|cx| {
3136 tool.clone().run(
3137 ToolInput::resolved(StreamingEditFileToolInput {
3138 display_description: "First edit".into(),
3139 path: "root/test.txt".into(),
3140 mode: StreamingEditFileMode::Edit,
3141 content: None,
3142 edits: Some(vec![Edit {
3143 old_text: "original content".into(),
3144 new_text: "modified content".into(),
3145 }]),
3146 }),
3147 ToolCallEventStream::test().0,
3148 cx,
3149 )
3150 })
3151 .await;
3152 assert!(
3153 edit_result.is_ok(),
3154 "First edit should succeed, got error: {:?}",
3155 edit_result.as_ref().err()
3156 );
3157
3158 // Second edit should also work because the edit updated the recorded read time
3159 let edit_result = cx
3160 .update(|cx| {
3161 tool.clone().run(
3162 ToolInput::resolved(StreamingEditFileToolInput {
3163 display_description: "Second edit".into(),
3164 path: "root/test.txt".into(),
3165 mode: StreamingEditFileMode::Edit,
3166 content: None,
3167 edits: Some(vec![Edit {
3168 old_text: "modified content".into(),
3169 new_text: "further modified content".into(),
3170 }]),
3171 }),
3172 ToolCallEventStream::test().0,
3173 cx,
3174 )
3175 })
3176 .await;
3177 assert!(
3178 edit_result.is_ok(),
3179 "Second consecutive edit should succeed, got error: {:?}",
3180 edit_result.as_ref().err()
3181 );
3182 }
3183
3184 #[gpui::test]
3185 async fn test_streaming_external_modification_detected(cx: &mut TestAppContext) {
3186 let (tool, project, action_log, fs, _thread) =
3187 setup_test(cx, json!({"test.txt": "original content"})).await;
3188 let read_tool = Arc::new(crate::ReadFileTool::new(
3189 project.clone(),
3190 action_log.clone(),
3191 true,
3192 ));
3193
3194 // Read the file first
3195 cx.update(|cx| {
3196 read_tool.clone().run(
3197 ToolInput::resolved(crate::ReadFileToolInput {
3198 path: "root/test.txt".to_string(),
3199 start_line: None,
3200 end_line: None,
3201 }),
3202 ToolCallEventStream::test().0,
3203 cx,
3204 )
3205 })
3206 .await
3207 .unwrap();
3208
3209 // Simulate external modification
3210 cx.background_executor
3211 .advance_clock(std::time::Duration::from_secs(2));
3212 fs.save(
3213 path!("/root/test.txt").as_ref(),
3214 &"externally modified content".into(),
3215 language::LineEnding::Unix,
3216 )
3217 .await
3218 .unwrap();
3219
3220 // Reload the buffer to pick up the new mtime
3221 let project_path = project
3222 .read_with(cx, |project, cx| {
3223 project.find_project_path("root/test.txt", cx)
3224 })
3225 .expect("Should find project path");
3226 let buffer = project
3227 .update(cx, |project, cx| project.open_buffer(project_path, cx))
3228 .await
3229 .unwrap();
3230 buffer
3231 .update(cx, |buffer, cx| buffer.reload(cx))
3232 .await
3233 .unwrap();
3234
3235 cx.executor().run_until_parked();
3236
3237 // Try to edit - should fail because file was modified externally
3238 let result = cx
3239 .update(|cx| {
3240 tool.clone().run(
3241 ToolInput::resolved(StreamingEditFileToolInput {
3242 display_description: "Edit after external change".into(),
3243 path: "root/test.txt".into(),
3244 mode: StreamingEditFileMode::Edit,
3245 content: None,
3246 edits: Some(vec![Edit {
3247 old_text: "externally modified content".into(),
3248 new_text: "new content".into(),
3249 }]),
3250 }),
3251 ToolCallEventStream::test().0,
3252 cx,
3253 )
3254 })
3255 .await;
3256
3257 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
3258 panic!("expected error");
3259 };
3260 assert!(
3261 error.contains("has been modified since you last read it"),
3262 "Error should mention file modification, got: {}",
3263 error
3264 );
3265 }
3266
3267 #[gpui::test]
3268 async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) {
3269 let (tool, project, action_log, _fs, _thread) =
3270 setup_test(cx, json!({"test.txt": "original content"})).await;
3271 let read_tool = Arc::new(crate::ReadFileTool::new(
3272 project.clone(),
3273 action_log.clone(),
3274 true,
3275 ));
3276
3277 // Read the file first
3278 cx.update(|cx| {
3279 read_tool.clone().run(
3280 ToolInput::resolved(crate::ReadFileToolInput {
3281 path: "root/test.txt".to_string(),
3282 start_line: None,
3283 end_line: None,
3284 }),
3285 ToolCallEventStream::test().0,
3286 cx,
3287 )
3288 })
3289 .await
3290 .unwrap();
3291
3292 // Open the buffer and make it dirty
3293 let project_path = project
3294 .read_with(cx, |project, cx| {
3295 project.find_project_path("root/test.txt", cx)
3296 })
3297 .expect("Should find project path");
3298 let buffer = project
3299 .update(cx, |project, cx| project.open_buffer(project_path, cx))
3300 .await
3301 .unwrap();
3302
3303 buffer.update(cx, |buffer, cx| {
3304 let end_point = buffer.max_point();
3305 buffer.edit([(end_point..end_point, " added text")], None, cx);
3306 });
3307
3308 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
3309 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
3310
3311 // Try to edit - should fail because buffer has unsaved changes
3312 let result = cx
3313 .update(|cx| {
3314 tool.clone().run(
3315 ToolInput::resolved(StreamingEditFileToolInput {
3316 display_description: "Edit with dirty buffer".into(),
3317 path: "root/test.txt".into(),
3318 mode: StreamingEditFileMode::Edit,
3319 content: None,
3320 edits: Some(vec![Edit {
3321 old_text: "original content".into(),
3322 new_text: "new content".into(),
3323 }]),
3324 }),
3325 ToolCallEventStream::test().0,
3326 cx,
3327 )
3328 })
3329 .await;
3330
3331 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
3332 panic!("expected error");
3333 };
3334 assert!(
3335 error.contains("This file has unsaved changes."),
3336 "Error should mention unsaved changes, got: {}",
3337 error
3338 );
3339 assert!(
3340 error.contains("keep or discard"),
3341 "Error should ask whether to keep or discard changes, got: {}",
3342 error
3343 );
3344 assert!(
3345 error.contains("save or revert the file manually"),
3346 "Error should ask user to manually save or revert when tools aren't available, got: {}",
3347 error
3348 );
3349 }
3350
3351 #[gpui::test]
3352 async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) {
3353 // Edit 1's replacement introduces text that contains edit 2's
3354 // old_text as a substring. Because edits resolve sequentially
3355 // against the current buffer, edit 2 finds a unique match in
3356 // the modified buffer and succeeds.
3357 let (tool, _project, _action_log, _fs, _thread) =
3358 setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await;
3359 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3360 let (event_stream, _receiver) = ToolCallEventStream::test();
3361 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3362
3363 // Setup: resolve the buffer
3364 sender.send_partial(json!({
3365 "display_description": "Overlapping edits",
3366 "path": "root/file.txt",
3367 "mode": "edit"
3368 }));
3369 cx.run_until_parked();
3370
3371 // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
3372 // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
3373 // Edit 2's old_text "ccc\nddd" matches the first occurrence
3374 // in the modified buffer and replaces it with "ZZZ".
3375 // Edit 3 exists only to mark edit 2 as "complete" during streaming.
3376 sender.send_partial(json!({
3377 "display_description": "Overlapping edits",
3378 "path": "root/file.txt",
3379 "mode": "edit",
3380 "edits": [
3381 {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3382 {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3383 {"old_text": "eee", "new_text": "DUMMY"}
3384 ]
3385 }));
3386 cx.run_until_parked();
3387
3388 // Send the final input with all three edits.
3389 sender.send_final(json!({
3390 "display_description": "Overlapping edits",
3391 "path": "root/file.txt",
3392 "mode": "edit",
3393 "edits": [
3394 {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"},
3395 {"old_text": "ccc\nddd", "new_text": "ZZZ"},
3396 {"old_text": "eee", "new_text": "DUMMY"}
3397 ]
3398 }));
3399
3400 let result = task.await;
3401 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3402 panic!("expected success");
3403 };
3404 assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
3405 }
3406
3407 #[gpui::test]
3408 async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) {
3409 let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
3410 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3411 let (event_stream, _receiver) = ToolCallEventStream::test();
3412 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3413
3414 // Transition to BufferResolved
3415 sender.send_partial(json!({
3416 "display_description": "Create new file",
3417 "path": "root/dir/new_file.txt",
3418 "mode": "write"
3419 }));
3420 cx.run_until_parked();
3421
3422 // Stream content incrementally
3423 sender.send_partial(json!({
3424 "display_description": "Create new file",
3425 "path": "root/dir/new_file.txt",
3426 "mode": "write",
3427 "content": "line 1\n"
3428 }));
3429 cx.run_until_parked();
3430
3431 // Verify buffer has partial content
3432 let buffer = project.update(cx, |project, cx| {
3433 let path = project
3434 .find_project_path("root/dir/new_file.txt", cx)
3435 .unwrap();
3436 project.get_open_buffer(&path, cx).unwrap()
3437 });
3438 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n");
3439
3440 // Stream more content
3441 sender.send_partial(json!({
3442 "display_description": "Create new file",
3443 "path": "root/dir/new_file.txt",
3444 "mode": "write",
3445 "content": "line 1\nline 2\n"
3446 }));
3447 cx.run_until_parked();
3448 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n");
3449
3450 // Stream final chunk
3451 sender.send_partial(json!({
3452 "display_description": "Create new file",
3453 "path": "root/dir/new_file.txt",
3454 "mode": "write",
3455 "content": "line 1\nline 2\nline 3\n"
3456 }));
3457 cx.run_until_parked();
3458 assert_eq!(
3459 buffer.read_with(cx, |b, _| b.text()),
3460 "line 1\nline 2\nline 3\n"
3461 );
3462
3463 // Send final input
3464 sender.send_final(json!({
3465 "display_description": "Create new file",
3466 "path": "root/dir/new_file.txt",
3467 "mode": "write",
3468 "content": "line 1\nline 2\nline 3\n"
3469 }));
3470
3471 let result = task.await;
3472 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3473 panic!("expected success");
3474 };
3475 assert_eq!(new_text, "line 1\nline 2\nline 3\n");
3476 }
3477
3478 #[gpui::test]
3479 async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) {
3480 let (tool, _project, _action_log, _fs, _thread) = setup_test(
3481 cx,
3482 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3483 )
3484 .await;
3485 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3486 let (event_stream, mut receiver) = ToolCallEventStream::test();
3487 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3488
3489 // Transition to BufferResolved
3490 sender.send_partial(json!({
3491 "display_description": "Overwrite file",
3492 "path": "root/file.txt",
3493 }));
3494 cx.run_until_parked();
3495
3496 sender.send_partial(json!({
3497 "display_description": "Overwrite file",
3498 "path": "root/file.txt",
3499 "mode": "write"
3500 }));
3501 cx.run_until_parked();
3502
3503 // Get the diff entity from the event stream
3504 receiver.expect_update_fields().await;
3505 let diff = receiver.expect_diff().await;
3506
3507 // Diff starts pending with no revealed ranges
3508 diff.read_with(cx, |diff, cx| {
3509 assert!(matches!(diff, Diff::Pending(_)));
3510 assert!(!diff.has_revealed_range(cx));
3511 });
3512
3513 // Stream first content chunk
3514 sender.send_partial(json!({
3515 "display_description": "Overwrite file",
3516 "path": "root/file.txt",
3517 "mode": "write",
3518 "content": "new line 1\n"
3519 }));
3520 cx.run_until_parked();
3521
3522 // Diff should now have revealed ranges showing the new content
3523 diff.read_with(cx, |diff, cx| {
3524 assert!(diff.has_revealed_range(cx));
3525 });
3526
3527 // Send final input
3528 sender.send_final(json!({
3529 "display_description": "Overwrite file",
3530 "path": "root/file.txt",
3531 "mode": "write",
3532 "content": "new line 1\nnew line 2\n"
3533 }));
3534
3535 let result = task.await;
3536 let StreamingEditFileToolOutput::Success {
3537 new_text, old_text, ..
3538 } = result.unwrap()
3539 else {
3540 panic!("expected success");
3541 };
3542 assert_eq!(new_text, "new line 1\nnew line 2\n");
3543 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3544
3545 // Diff is finalized after completion
3546 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
3547 }
3548
3549 #[gpui::test]
3550 async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) {
3551 let (tool, project, _action_log, _fs, _thread) = setup_test(
3552 cx,
3553 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
3554 )
3555 .await;
3556 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3557 let (event_stream, _receiver) = ToolCallEventStream::test();
3558 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3559
3560 // Transition to BufferResolved
3561 sender.send_partial(json!({
3562 "display_description": "Overwrite file",
3563 "path": "root/file.txt",
3564 "mode": "write"
3565 }));
3566 cx.run_until_parked();
3567
3568 // Verify buffer still has old content (no content partial yet)
3569 let buffer = project.update(cx, |project, cx| {
3570 let path = project.find_project_path("root/file.txt", cx).unwrap();
3571 project.open_buffer(path, cx)
3572 });
3573 let buffer = buffer.await.unwrap();
3574 assert_eq!(
3575 buffer.read_with(cx, |b, _| b.text()),
3576 "old line 1\nold line 2\nold line 3\n"
3577 );
3578
3579 // First content partial replaces old content
3580 sender.send_partial(json!({
3581 "display_description": "Overwrite file",
3582 "path": "root/file.txt",
3583 "mode": "write",
3584 "content": "new line 1\n"
3585 }));
3586 cx.run_until_parked();
3587 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n");
3588
3589 // Subsequent content partials append
3590 sender.send_partial(json!({
3591 "display_description": "Overwrite file",
3592 "path": "root/file.txt",
3593 "mode": "write",
3594 "content": "new line 1\nnew line 2\n"
3595 }));
3596 cx.run_until_parked();
3597 assert_eq!(
3598 buffer.read_with(cx, |b, _| b.text()),
3599 "new line 1\nnew line 2\n"
3600 );
3601
3602 // Send final input with complete content
3603 sender.send_final(json!({
3604 "display_description": "Overwrite file",
3605 "path": "root/file.txt",
3606 "mode": "write",
3607 "content": "new line 1\nnew line 2\nnew line 3\n"
3608 }));
3609
3610 let result = task.await;
3611 let StreamingEditFileToolOutput::Success {
3612 new_text, old_text, ..
3613 } = result.unwrap()
3614 else {
3615 panic!("expected success");
3616 };
3617 assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n");
3618 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
3619 }
3620
3621 #[gpui::test]
3622 async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) {
3623 let (tool, _project, _action_log, _fs, _thread) =
3624 setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await;
3625 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3626 let (event_stream, _receiver) = ToolCallEventStream::test();
3627 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3628
3629 sender.send_partial(json!({
3630 "display_description": "Edit",
3631 "path": "root/file.txt",
3632 "mode": "edit"
3633 }));
3634 cx.run_until_parked();
3635
3636 // Simulate JSON fixer producing a literal backslash when the LLM
3637 // stream cuts in the middle of a \n escape sequence.
3638 // The old_text "hello\nworld" would be streamed as:
3639 // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\)
3640 // partial 2: old_text = "hello\nworld" (fixer corrected the escape)
3641 sender.send_partial(json!({
3642 "display_description": "Edit",
3643 "path": "root/file.txt",
3644 "mode": "edit",
3645 "edits": [{"old_text": "hello\\"}]
3646 }));
3647 cx.run_until_parked();
3648
3649 // Now the fixer corrects it to the real newline.
3650 sender.send_partial(json!({
3651 "display_description": "Edit",
3652 "path": "root/file.txt",
3653 "mode": "edit",
3654 "edits": [{"old_text": "hello\nworld"}]
3655 }));
3656 cx.run_until_parked();
3657
3658 // Send final.
3659 sender.send_final(json!({
3660 "display_description": "Edit",
3661 "path": "root/file.txt",
3662 "mode": "edit",
3663 "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}]
3664 }));
3665
3666 let result = task.await;
3667 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3668 panic!("expected success");
3669 };
3670 assert_eq!(new_text, "HELLO\nWORLD\nfoo\n");
3671 }
3672
3673 // Verifies that after streaming_edit_file_tool edits a file, the action log
3674 // reports changed buffers so that the Accept All / Reject All review UI appears.
3675 #[gpui::test]
3676 async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) {
3677 let (tool, _project, action_log, _fs, _thread) =
3678 setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await;
3679 cx.update(|cx| {
3680 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3681 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3682 agent_settings::AgentSettings::override_global(settings, cx);
3683 });
3684
3685 let (event_stream, _rx) = ToolCallEventStream::test();
3686 let task = cx.update(|cx| {
3687 tool.clone().run(
3688 ToolInput::resolved(StreamingEditFileToolInput {
3689 display_description: "Edit lines".to_string(),
3690 path: "root/file.txt".into(),
3691 mode: StreamingEditFileMode::Edit,
3692 content: None,
3693 edits: Some(vec![Edit {
3694 old_text: "line 2".into(),
3695 new_text: "modified line 2".into(),
3696 }]),
3697 }),
3698 event_stream,
3699 cx,
3700 )
3701 });
3702
3703 let result = task.await;
3704 assert!(result.is_ok(), "edit should succeed: {:?}", result.err());
3705
3706 cx.run_until_parked();
3707
3708 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3709 assert!(
3710 !changed.is_empty(),
3711 "action_log.changed_buffers() should be non-empty after streaming edit,
3712 but no changed buffers were found - Accept All / Reject All will not appear"
3713 );
3714 }
3715
3716 // Same test but for Write mode (overwrite entire file).
3717 #[gpui::test]
3718 async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers(
3719 cx: &mut TestAppContext,
3720 ) {
3721 let (tool, _project, action_log, _fs, _thread) =
3722 setup_test(cx, json!({"file.txt": "original content"})).await;
3723 cx.update(|cx| {
3724 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3725 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3726 agent_settings::AgentSettings::override_global(settings, cx);
3727 });
3728
3729 let (event_stream, _rx) = ToolCallEventStream::test();
3730 let task = cx.update(|cx| {
3731 tool.clone().run(
3732 ToolInput::resolved(StreamingEditFileToolInput {
3733 display_description: "Overwrite file".to_string(),
3734 path: "root/file.txt".into(),
3735 mode: StreamingEditFileMode::Write,
3736 content: Some("completely new content".into()),
3737 edits: None,
3738 }),
3739 event_stream,
3740 cx,
3741 )
3742 });
3743
3744 let result = task.await;
3745 assert!(result.is_ok(), "write should succeed: {:?}", result.err());
3746
3747 cx.run_until_parked();
3748
3749 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3750 assert!(
3751 !changed.is_empty(),
3752 "action_log.changed_buffers() should be non-empty after streaming write, \
3753 but no changed buffers were found \u{2014} Accept All / Reject All will not appear"
3754 );
3755 }
3756
3757 #[gpui::test]
3758 async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
3759 cx: &mut TestAppContext,
3760 ) {
3761 let (tool, _project, _action_log, _fs, _thread) =
3762 setup_test(cx, json!({"file.txt": "old_content"})).await;
3763 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3764 let (event_stream, _receiver) = ToolCallEventStream::test();
3765 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3766
3767 sender.send_partial(json!({
3768 "display_description": "Overwrite file",
3769 "mode": "write"
3770 }));
3771 cx.run_until_parked();
3772
3773 sender.send_partial(json!({
3774 "display_description": "Overwrite file",
3775 "mode": "write",
3776 "content": "new_content"
3777 }));
3778 cx.run_until_parked();
3779
3780 sender.send_partial(json!({
3781 "display_description": "Overwrite file",
3782 "mode": "write",
3783 "content": "new_content",
3784 "path": "root"
3785 }));
3786 cx.run_until_parked();
3787
3788 // Send final.
3789 sender.send_final(json!({
3790 "display_description": "Overwrite file",
3791 "mode": "write",
3792 "content": "new_content",
3793 "path": "root/file.txt"
3794 }));
3795
3796 let result = task.await;
3797 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3798 panic!("expected success");
3799 };
3800 assert_eq!(new_text, "new_content");
3801 }
3802
3803 #[gpui::test]
3804 async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
3805 cx: &mut TestAppContext,
3806 ) {
3807 let (tool, _project, _action_log, _fs, _thread) =
3808 setup_test(cx, json!({"file.txt": "old_content"})).await;
3809 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
3810 let (event_stream, _receiver) = ToolCallEventStream::test();
3811 let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
3812
3813 sender.send_partial(json!({
3814 "display_description": "Overwrite file",
3815 "mode": "edit"
3816 }));
3817 cx.run_until_parked();
3818
3819 sender.send_partial(json!({
3820 "display_description": "Overwrite file",
3821 "mode": "edit",
3822 "edits": [{"old_text": "old_content"}]
3823 }));
3824 cx.run_until_parked();
3825
3826 sender.send_partial(json!({
3827 "display_description": "Overwrite file",
3828 "mode": "edit",
3829 "edits": [{"old_text": "old_content", "new_text": "new_content"}]
3830 }));
3831 cx.run_until_parked();
3832
3833 sender.send_partial(json!({
3834 "display_description": "Overwrite file",
3835 "mode": "edit",
3836 "edits": [{"old_text": "old_content", "new_text": "new_content"}],
3837 "path": "root"
3838 }));
3839 cx.run_until_parked();
3840
3841 // Send final.
3842 sender.send_final(json!({
3843 "display_description": "Overwrite file",
3844 "mode": "edit",
3845 "edits": [{"old_text": "old_content", "new_text": "new_content"}],
3846 "path": "root/file.txt"
3847 }));
3848 cx.run_until_parked();
3849
3850 let result = task.await;
3851 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
3852 panic!("expected success");
3853 };
3854 assert_eq!(new_text, "new_content");
3855 }
3856
3857 #[gpui::test]
3858 async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) {
3859 let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await;
3860 cx.update(|cx| {
3861 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
3862 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
3863 agent_settings::AgentSettings::override_global(settings, cx);
3864 });
3865
3866 // Create a new file via the streaming edit file tool
3867 let (event_stream, _rx) = ToolCallEventStream::test();
3868 let task = cx.update(|cx| {
3869 tool.clone().run(
3870 ToolInput::resolved(StreamingEditFileToolInput {
3871 display_description: "Create new file".into(),
3872 path: "root/dir/new_file.txt".into(),
3873 mode: StreamingEditFileMode::Write,
3874 content: Some("Hello, World!".into()),
3875 edits: None,
3876 }),
3877 event_stream,
3878 cx,
3879 )
3880 });
3881 let result = task.await;
3882 assert!(result.is_ok(), "create should succeed: {:?}", result.err());
3883 cx.run_until_parked();
3884
3885 assert!(
3886 fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
3887 "file should exist after creation"
3888 );
3889
3890 // Reject all edits — this should delete the newly created file
3891 let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx));
3892 assert!(
3893 !changed.is_empty(),
3894 "action_log should track the created file as changed"
3895 );
3896
3897 action_log
3898 .update(cx, |log, cx| log.reject_all_edits(None, cx))
3899 .await;
3900 cx.run_until_parked();
3901
3902 assert!(
3903 !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
3904 "file should be deleted after rejecting creation, but an empty file was left behind"
3905 );
3906 }
3907
3908 async fn setup_test_with_fs(
3909 cx: &mut TestAppContext,
3910 fs: Arc<project::FakeFs>,
3911 worktree_paths: &[&std::path::Path],
3912 ) -> (
3913 Arc<StreamingEditFileTool>,
3914 Entity<Project>,
3915 Entity<ActionLog>,
3916 Arc<project::FakeFs>,
3917 Entity<Thread>,
3918 ) {
3919 let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await;
3920 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
3921 let context_server_registry =
3922 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
3923 let model = Arc::new(FakeLanguageModel::default());
3924 let thread = cx.new(|cx| {
3925 crate::Thread::new(
3926 project.clone(),
3927 cx.new(|_cx| ProjectContext::default()),
3928 context_server_registry,
3929 Templates::new(),
3930 Some(model),
3931 cx,
3932 )
3933 });
3934 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
3935 let tool = Arc::new(StreamingEditFileTool::new(
3936 project.clone(),
3937 thread.downgrade(),
3938 action_log.clone(),
3939 language_registry,
3940 ));
3941 (tool, project, action_log, fs, thread)
3942 }
3943
3944 async fn setup_test(
3945 cx: &mut TestAppContext,
3946 initial_tree: serde_json::Value,
3947 ) -> (
3948 Arc<StreamingEditFileTool>,
3949 Entity<Project>,
3950 Entity<ActionLog>,
3951 Arc<project::FakeFs>,
3952 Entity<Thread>,
3953 ) {
3954 init_test(cx);
3955 let fs = project::FakeFs::new(cx.executor());
3956 fs.insert_tree("/root", initial_tree).await;
3957 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await
3958 }
3959
3960 fn init_test(cx: &mut TestAppContext) {
3961 cx.update(|cx| {
3962 let settings_store = SettingsStore::test(cx);
3963 cx.set_global(settings_store);
3964 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
3965 store.update_user_settings(cx, |settings| {
3966 settings
3967 .project
3968 .all_languages
3969 .defaults
3970 .ensure_final_newline_on_save = Some(false);
3971 });
3972 });
3973 });
3974 }
3975}