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