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