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