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