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