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