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