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