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