History

agent: Stream `new_text` in `StreamingEditFileTool` (#50240)

We now stream the new text into the buffer as soon as we receive partial chunks of `new_text`. This is pretty much a full re-write of the way streaming worked, which is now much closer to how the edit agent works: - `ToolEditParser` buffers chunks as they stream in, and emits relevant events (`OldTextChunk`,`NewTextChunk`, ...) that we use to power the `EditSession` pipeline. - `EditSession::process_events` takes care of consuming these events and applying the edits incrementally as chunks stream in. `EditPipeline` maintains the underlying state machine for each edit. - We handle whitespace mismatches similar to the edit agent, the code is shared by moving that logic to `reindent.rs` Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A

Bennet Bo Fenner committed 8 months ago

Commit c9425f21 parent 78878e55 files · +2098 −1102Browse files

Unified · Split

5 files
  1. crates/agent/src/edit_agent.rs +8 −81
  2. crates/agent/src/edit_agent/reindent.rs +214 −0
  3. crates/agent/src/tools.rs +1 −0
  4. crates/agent/src/tools/streaming_edit_file_tool.rs +934 −1021
  5. crates/agent/src/tools/tool_edit_parser.rs +941 −0
crates/agent/src/edit_agent.rs+8−81
@@ -2,6 +2,7 @@ mod create_file_parser; · 1 unmodified line
2mod edit_parser;2mod edit_parser;
3#[cfg(test)]3#[cfg(test)]
4mod evals;4mod evals;
5pub mod reindent;
5pub mod streaming_fuzzy_matcher;6pub mod streaming_fuzzy_matcher;
6 7
7use crate::{Template, Templates};8use crate::{Template, Templates};
@@ -24,9 +25,10 @@ use language_model::{ · 16 unmodified lines
24 LanguageModelToolChoice, MessageContent, Role,25 LanguageModelToolChoice, MessageContent, Role,
25};26};
26use project::{AgentLocation, Project};27use project::{AgentLocation, Project};
28use reindent::{IndentDelta, Reindenter};
27use schemars::JsonSchema;29use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};30use serde::{Deserialize, Serialize};
29use std::{cmp, iter, mem, ops::Range, pin::Pin, sync::Arc, task::Poll};31use std::{mem, ops::Range, pin::Pin, sync::Arc, task::Poll};
30use streaming_diff::{CharOperation, StreamingDiff};32use streaming_diff::{CharOperation, StreamingDiff};
31use streaming_fuzzy_matcher::StreamingFuzzyMatcher;33use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
32 34
@@ -553,15 +555,8 @@ impl EditAgent { · 520 unmodified lines
553 let compute_edits = cx.background_spawn(async move {555 let compute_edits = cx.background_spawn(async move {
554 let buffer_start_indent = snapshot556 let buffer_start_indent = snapshot
555 .line_indent_for_row(snapshot.offset_to_point(resolved_old_text.range.start).row);557 .line_indent_for_row(snapshot.offset_to_point(resolved_old_text.range.start).row);
556 let indent_delta = if buffer_start_indent.tabs > 0 {558 let indent_delta =
557 IndentDelta::Tabs(559 reindent::compute_indent_delta(buffer_start_indent, resolved_old_text.indent);
558 buffer_start_indent.tabs as isize - resolved_old_text.indent.tabs as isize,
559 )
560 } else {
561 IndentDelta::Spaces(
562 buffer_start_indent.spaces as isize - resolved_old_text.indent.spaces as isize,
563 )
564 };
565 560
566 let old_text = snapshot561 let old_text = snapshot
567 .text_for_range(resolved_old_text.range.clone())562 .text_for_range(resolved_old_text.range.clone())
@@ -608,8 +603,7 @@ impl EditAgent { · 40 unmodified lines
608 delta: IndentDelta,603 delta: IndentDelta,
609 mut stream: impl Unpin + Stream<Item = Result<EditParserEvent>>,604 mut stream: impl Unpin + Stream<Item = Result<EditParserEvent>>,
610 ) -> impl Stream<Item = Result<String>> {605 ) -> impl Stream<Item = Result<String>> {
611 let mut buffer = String::new();606 let mut reindenter = Reindenter::new(delta);
612 let mut in_leading_whitespace = true;
613 let mut done = false;607 let mut done = false;
614 futures::stream::poll_fn(move |cx| {608 futures::stream::poll_fn(move |cx| {
615 while !done {609 while !done {
@@ -622,55 +616,10 @@ impl EditAgent { · 6 unmodified lines
622 _ => return Poll::Ready(None),616 _ => return Poll::Ready(None),
623 };617 };
624 618
625 buffer.push_str(&chunk);619 let mut indented_new_text = reindenter.push(&chunk);
626
627 let mut indented_new_text = String::new();
628 let mut start_ix = 0;
629 let mut newlines = buffer.match_indices('\n').peekable();
630 loop {
631 let (line_end, is_pending_line) = match newlines.next() {
632 Some((ix, _)) => (ix, false),
633 None => (buffer.len(), true),
634 };
635 let line = &buffer[start_ix..line_end];
636
637 if in_leading_whitespace {
638 if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) {
639 // We found a non-whitespace character, adjust
640 // indentation based on the delta.
641 let new_indent_len =
642 cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize;
643 indented_new_text
644 .extend(iter::repeat(delta.character()).take(new_indent_len));
645 indented_new_text.push_str(&line[non_whitespace_ix..]);
646 in_leading_whitespace = false;
647 } else if is_pending_line {
648 // We're still in leading whitespace and this line is incomplete.
649 // Stop processing until we receive more input.
650 break;
651 } else {
652 // This line is entirely whitespace. Push it without indentation.
653 indented_new_text.push_str(line);
654 }
655 } else {
656 indented_new_text.push_str(line);
657 }
658
659 if is_pending_line {
660 start_ix = line_end;
661 break;
662 } else {
663 in_leading_whitespace = true;
664 indented_new_text.push('\n');
665 start_ix = line_end + 1;
666 }
667 }
668 buffer.replace_range(..start_ix, "");
669
670 // This was the last chunk, push all the buffered content as-is.620 // This was the last chunk, push all the buffered content as-is.
671 if is_last_chunk {621 if is_last_chunk {
672 indented_new_text.push_str(&buffer);622 indented_new_text.push_str(&reindenter.finish());
673 buffer.clear();
674 done = true;623 done = true;
675 }624 }
676 625
@@ -761,28 +710,6 @@ struct ResolvedOldText { · 84 unmodified lines
761 indent: LineIndent,710 indent: LineIndent,
762}711}
763 712
764#[derive(Copy, Clone, Debug)]
765enum IndentDelta {
766 Spaces(isize),
767 Tabs(isize),
768}
769
770impl IndentDelta {
771 fn character(&self) -> char {
772 match self {
773 IndentDelta::Spaces(_) => ' ',
774 IndentDelta::Tabs(_) => '\t',
775 }
776 }
777
778 fn len(&self) -> isize {
779 match self {
780 IndentDelta::Spaces(n) => *n,
781 IndentDelta::Tabs(n) => *n,
782 }
783 }
784}
785
786#[cfg(test)]713#[cfg(test)]
787mod tests {714mod tests {
788 use super::*;715 use super::*;
crates/agent/src/edit_agent/reindent.rs+214−0
@@ -0,0 +1,214 @@
1use language::LineIndent;
2use std::{cmp, iter};
3
4#[derive(Copy, Clone, Debug)]
5pub enum IndentDelta {
6 Spaces(isize),
7 Tabs(isize),
8}
9
10impl IndentDelta {
11 pub fn character(&self) -> char {
12 match self {
13 IndentDelta::Spaces(_) => ' ',
14 IndentDelta::Tabs(_) => '\t',
15 }
16 }
17
18 pub fn len(&self) -> isize {
19 match self {
20 IndentDelta::Spaces(n) => *n,
21 IndentDelta::Tabs(n) => *n,
22 }
23 }
24}
25
26pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent) -> IndentDelta {
27 if buffer_indent.tabs > 0 {
28 IndentDelta::Tabs(buffer_indent.tabs as isize - query_indent.tabs as isize)
29 } else {
30 IndentDelta::Spaces(buffer_indent.spaces as isize - query_indent.spaces as isize)
31 }
32}
33
34/// Synchronous re-indentation adapter. Buffers incomplete lines and applies
35/// an `IndentDelta` to each line's leading whitespace before emitting it.
36pub struct Reindenter {
37 delta: IndentDelta,
38 buffer: String,
39 in_leading_whitespace: bool,
40}
41
42impl Reindenter {
43 pub fn new(delta: IndentDelta) -> Self {
44 Self {
45 delta,
46 buffer: String::new(),
47 in_leading_whitespace: true,
48 }
49 }
50
51 /// Feed a chunk of text and return the re-indented portion that is
52 /// ready to emit. Incomplete trailing lines are buffered internally.
53 pub fn push(&mut self, chunk: &str) -> String {
54 self.buffer.push_str(chunk);
55 self.drain(false)
56 }
57
58 /// Flush any remaining buffered content (call when the stream is done).
59 pub fn finish(&mut self) -> String {
60 self.drain(true)
61 }
62
63 fn drain(&mut self, is_final: bool) -> String {
64 let mut indented = String::new();
65 let mut start_ix = 0;
66 let mut newlines = self.buffer.match_indices('\n');
67 loop {
68 let (line_end, is_pending_line) = match newlines.next() {
69 Some((ix, _)) => (ix, false),
70 None => (self.buffer.len(), true),
71 };
72 let line = &self.buffer[start_ix..line_end];
73
74 if self.in_leading_whitespace {
75 if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) {
76 // We found a non-whitespace character, adjust indentation
77 // based on the delta.
78 let new_indent_len =
79 cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize;
80 indented.extend(iter::repeat(self.delta.character()).take(new_indent_len));
81 indented.push_str(&line[non_whitespace_ix..]);
82 self.in_leading_whitespace = false;
83 } else if is_pending_line && !is_final {
84 // We're still in leading whitespace and this line is incomplete.
85 // Stop processing until we receive more input.
86 break;
87 } else {
88 // This line is entirely whitespace. Push it without indentation.
89 indented.push_str(line);
90 }
91 } else {
92 indented.push_str(line);
93 }
94
95 if is_pending_line {
96 start_ix = line_end;
97 break;
98 } else {
99 self.in_leading_whitespace = true;
100 indented.push('\n');
101 start_ix = line_end + 1;
102 }
103 }
104 self.buffer.replace_range(..start_ix, "");
105 if is_final {
106 indented.push_str(&self.buffer);
107 self.buffer.clear();
108 }
109 indented
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn test_indent_single_chunk() {
119 let mut r = Reindenter::new(IndentDelta::Spaces(2));
120 let out = r.push(" abc\n def\n ghi");
121 // All three lines are emitted: "ghi" starts with spaces but
122 // contains non-whitespace, so it's processed immediately.
123 assert_eq!(out, " abc\n def\n ghi");
124 let out = r.finish();
125 assert_eq!(out, "");
126 }
127
128 #[test]
129 fn test_outdent_tabs() {
130 let mut r = Reindenter::new(IndentDelta::Tabs(-2));
131 let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi");
132 assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi");
133 let out = r.finish();
134 assert_eq!(out, "");
135 }
136
137 #[test]
138 fn test_incremental_chunks() {
139 let mut r = Reindenter::new(IndentDelta::Spaces(2));
140 // Feed " ab" — the `a` is non-whitespace, so the line is
141 // processed immediately even without a trailing newline.
142 let out = r.push(" ab");
143 assert_eq!(out, " ab");
144 // Feed "c\n" — appended to the already-processed line (no longer
145 // in leading whitespace).
146 let out = r.push("c\n");
147 assert_eq!(out, "c\n");
148 let out = r.finish();
149 assert_eq!(out, "");
150 }
151
152 #[test]
153 fn test_zero_delta() {
154 let mut r = Reindenter::new(IndentDelta::Spaces(0));
155 let out = r.push(" hello\n world\n");
156 assert_eq!(out, " hello\n world\n");
157 let out = r.finish();
158 assert_eq!(out, "");
159 }
160
161 #[test]
162 fn test_clamp_negative_indent() {
163 let mut r = Reindenter::new(IndentDelta::Spaces(-10));
164 let out = r.push(" abc\n");
165 // max(0, 2 - 10) = 0, so no leading spaces.
166 assert_eq!(out, "abc\n");
167 let out = r.finish();
168 assert_eq!(out, "");
169 }
170
171 #[test]
172 fn test_whitespace_only_lines() {
173 let mut r = Reindenter::new(IndentDelta::Spaces(2));
174 let out = r.push(" \n code\n");
175 // First line is all whitespace — emitted verbatim. Second line is indented.
176 assert_eq!(out, " \n code\n");
177 let out = r.finish();
178 assert_eq!(out, "");
179 }
180
181 #[test]
182 fn test_compute_indent_delta_spaces() {
183 let buffer = LineIndent {
184 tabs: 0,
185 spaces: 8,
186 line_blank: false,
187 };
188 let query = LineIndent {
189 tabs: 0,
190 spaces: 4,
191 line_blank: false,
192 };
193 let delta = compute_indent_delta(buffer, query);
194 assert_eq!(delta.len(), 4);
195 assert_eq!(delta.character(), ' ');
196 }
197
198 #[test]
199 fn test_compute_indent_delta_tabs() {
200 let buffer = LineIndent {
201 tabs: 2,
202 spaces: 0,
203 line_blank: false,
204 };
205 let query = LineIndent {
206 tabs: 3,
207 spaces: 0,
208 line_blank: false,
209 };
210 let delta = compute_indent_delta(buffer, query);
211 assert_eq!(delta.len(), -1);
212 assert_eq!(delta.character(), '\t');
213 }
214}
crates/agent/src/tools.rs+1−0
@@ -17,6 +17,7 @@ mod save_file_tool; · 16 unmodified lines
17mod spawn_agent_tool;17mod spawn_agent_tool;
18mod streaming_edit_file_tool;18mod streaming_edit_file_tool;
19mod terminal_tool;19mod terminal_tool;
20mod tool_edit_parser;
20mod tool_permissions;21mod tool_permissions;
21mod web_search_tool;22mod web_search_tool;
22 23
crates/agent/src/tools/streaming_edit_file_tool.rs+934−1021
@@ -1,13 +1,17 @@
1use super::edit_file_tool::EditFileTool;1use super::edit_file_tool::EditFileTool;
2use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;2use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
3use super::save_file_tool::SaveFileTool;3use super::save_file_tool::SaveFileTool;
4use super::tool_edit_parser::{ToolEditEvent, ToolEditParser};
4use crate::{5use crate::{
5 AgentTool, Thread, ToolCallEventStream, ToolInput,6 AgentTool, Thread, ToolCallEventStream, ToolInput,
6 edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher,7 edit_agent::{
8 reindent::{Reindenter, compute_indent_delta},
9 streaming_fuzzy_matcher::StreamingFuzzyMatcher,
10 },
7};11};
8use acp_thread::Diff;12use acp_thread::Diff;
9use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};13use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10use anyhow::{Context as _, Result, anyhow};14use anyhow::{Context as _, Result};
11use collections::HashSet;15use collections::HashSet;
12use futures::FutureExt as _;16use futures::FutureExt as _;
13use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};17use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
@@ -15,16 +19,15 @@ use language::language_settings::{self, FormatOnSave}; · 1 unmodified line
15use language::{Buffer, LanguageRegistry};19use language::{Buffer, LanguageRegistry};
16use language_model::LanguageModelToolResultContent;20use language_model::LanguageModelToolResultContent;
17use project::lsp_store::{FormatTrigger, LspFormatTarget};21use project::lsp_store::{FormatTrigger, LspFormatTarget};
18use project::{Project, ProjectPath};22use project::{AgentLocation, Project, ProjectPath};
19use schemars::JsonSchema;23use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};24use serde::{Deserialize, Serialize};
21use std::ops::Range;
22use std::path::PathBuf;25use std::path::PathBuf;
23use std::sync::Arc;26use std::sync::Arc;
24use text::{BufferSnapshot, ToOffset as _};27use streaming_diff::{CharOperation, StreamingDiff};
25use ui::SharedString;28use ui::SharedString;
26use util::rel_path::RelPath;29use util::rel_path::RelPath;
27use util::{Deferred, ResultExt, debug_panic};30use util::{Deferred, ResultExt};
28 31
29const DEFAULT_UI_TEXT: &str = "Editing file";32const DEFAULT_UI_TEXT: &str = "Editing file";
30 33
@@ -70,14 +73,13 @@ pub struct StreamingEditFileToolInput { · 39 unmodified lines
70 pub path: String,73 pub path: String,
71 74
72 /// The mode of operation on the file. Possible values:75 /// The mode of operation on the file. Possible values:
73 /// - 'create': Create a new file if it doesn't exist. Requires 'content' field.76 /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field.
74 /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field.
75 /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.77 /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.
76 ///78 ///
77 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.79 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
78 pub mode: StreamingEditFileMode,80 pub mode: StreamingEditFileMode,
79 81
80 /// The complete content for the new file (required for 'create' and 'overwrite' modes).82 /// The complete content for the new file (required for 'write' mode).
81 /// This field should contain the entire file content.83 /// This field should contain the entire file content.
82 #[serde(default, skip_serializing_if = "Option::is_none")]84 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub content: Option<String>,85 pub content: Option<String>,
@@ -85,23 +87,22 @@ pub struct StreamingEditFileToolInput { · 1 unmodified line
85 /// List of edit operations to apply sequentially (required for 'edit' mode).87 /// List of edit operations to apply sequentially (required for 'edit' mode).
86 /// Each edit finds `old_text` in the file and replaces it with `new_text`.88 /// Each edit finds `old_text` in the file and replaces it with `new_text`.
87 #[serde(default, skip_serializing_if = "Option::is_none")]89 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub edits: Option<Vec<EditOperation>>,90 pub edits: Option<Vec<Edit>>,
89}91}
90 92
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]93#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]94#[serde(rename_all = "snake_case")]
93pub enum StreamingEditFileMode {95pub enum StreamingEditFileMode {
94 /// Create a new file if it doesn't exist96 /// Overwrite the file with new content (replacing any existing content).
95 Create,97 /// If the file does not exist, it will be created.
96 /// Replace the entire contents of an existing file98 Write,
97 Overwrite,
98 /// Make granular edits to an existing file99 /// Make granular edits to an existing file
99 Edit,100 Edit,
100}101}
101 102
102/// A single edit operation that replaces old text with new text103/// A single edit operation that replaces old text with new text
103#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]104#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
104pub struct EditOperation {105pub struct Edit {
105 /// The exact text to find in the file. This will be matched using fuzzy matching106 /// The exact text to find in the file. This will be matched using fuzzy matching
106 /// to handle minor differences in whitespace or formatting.107 /// to handle minor differences in whitespace or formatting.
107 pub old_text: String,108 pub old_text: String,
@@ -118,271 +119,328 @@ struct StreamingEditFileToolPartialInput { · 10 unmodified lines
118 #[serde(default)]119 #[serde(default)]
119 mode: Option<StreamingEditFileMode>,120 mode: Option<StreamingEditFileMode>,
120 #[serde(default)]121 #[serde(default)]
121 #[allow(dead_code)]
122 content: Option<String>,122 content: Option<String>,
123 #[serde(default)]123 #[serde(default)]
124 edits: Option<Vec<PartialEditOperation>>,124 edits: Option<Vec<PartialEdit>>,
125}125}
126 126
127#[derive(Default, Debug, Deserialize)]127#[derive(Default, Debug, Deserialize)]
128struct PartialEditOperation {128pub struct PartialEdit {
129 #[serde(default)]129 #[serde(default)]
130 old_text: Option<String>,130 pub old_text: Option<String>,
131 #[serde(default)]131 #[serde(default)]
132 new_text: Option<String>,132 pub new_text: Option<String>,
133}133}
134 134
135enum StreamingEditState {135#[derive(Debug, Serialize, Deserialize)]
136 Idle,136#[serde(untagged)]
137 BufferResolved {137pub enum StreamingEditFileToolOutput {
138 abs_path: PathBuf,138 Success {
139 buffer: Entity<Buffer>,139 #[serde(alias = "original_path")]
140 input_path: PathBuf,
141 new_text: String,
140 old_text: Arc<String>,142 old_text: Arc<String>,
141 diff: Entity<Diff>,143 #[serde(default)]
142 mode: StreamingEditFileMode,144 diff: String,
143 last_content_len: usize,145 },
144 edit_state: IncrementalEditState,146 Error {
145 _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,147 error: String,
146 },148 },
147}149}
148 150
149#[derive(Default)]151impl StreamingEditFileToolOutput {
150struct IncrementalEditState {152 pub fn error(error: impl Into<String>) -> Self {
151 in_progress_matcher: Option<StreamingFuzzyMatcher>,153 Self::Error {
152 last_old_text_len: usize,154 error: error.into(),
153 applied_ranges: Vec<Range<text::Anchor>>,155 }
156 }
157}
158
159impl std::fmt::Display for StreamingEditFileToolOutput {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 StreamingEditFileToolOutput::Success {
163 diff, input_path, ..
164 } => {
165 if diff.is_empty() {
166 write!(f, "No edits were made.")
167 } else {
168 write!(
169 f,
170 "Edited {}:\n\n```diff\n{diff}\n```",
171 input_path.display()
172 )
173 }
174 }
175 StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),
176 }
177 }
154}178}
155 179
156impl IncrementalEditState {180impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
157 fn applied_count(&self) -> usize {181 fn from(output: StreamingEditFileToolOutput) -> Self {
158 self.applied_ranges.len()182 output.to_string().into()
159 }183 }
160}184}
161 185
162impl StreamingEditState {186pub struct StreamingEditFileTool {
163 async fn finalize(187 thread: WeakEntity<Thread>,
164 &mut self,188 language_registry: Arc<LanguageRegistry>,
165 input: StreamingEditFileToolInput,189 project: Entity<Project>,
166 tool: &StreamingEditFileTool,190}
191
192impl StreamingEditFileTool {
193 pub fn new(
194 project: Entity<Project>,
195 thread: WeakEntity<Thread>,
196 language_registry: Arc<LanguageRegistry>,
197 ) -> Self {
198 Self {
199 project,
200 thread,
201 language_registry,
202 }
203 }
204
205 fn authorize(
206 &self,
207 path: &PathBuf,
208 description: &str,
167 event_stream: &ToolCallEventStream,209 event_stream: &ToolCallEventStream,
168 cx: &mut AsyncApp,210 cx: &mut App,
169 ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {211 ) -> Task<Result<()>> {
170 let remaining_edits_start_ix = match self {212 super::tool_permissions::authorize_file_edit(
171 StreamingEditState::Idle => {213 EditFileTool::NAME,
172 *self = Self::transition_to_buffer_resolved(214 path,
173 &input.path,215 description,
174 &input.display_description,216 &self.thread,
175 input.mode.clone(),217 event_stream,
176 tool,218 cx,
177 event_stream,219 )
178 cx,220 }
179 )
180 .await?;
181 0
182 }
183 StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(),
184 };
185 221
186 let StreamingEditState::BufferResolved {222 fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
187 buffer,223 self.project.update(cx, |project, cx| {
188 old_text,224 project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
189 diff,225 });
190 abs_path,226 }
191 ..227}
192 } = self
193 else {
194 debug_panic!("Invalid state");
195 return Ok(StreamingEditFileToolOutput::Error {
196 error: "Internal error. Try to apply the edits again".to_string(),
197 });
198 };
199 228
200 let result: anyhow::Result<StreamingEditFileToolOutput> = async {229impl AgentTool for StreamingEditFileTool {
201 let action_log = tool230 type Input = StreamingEditFileToolInput;
202 .thread231 type Output = StreamingEditFileToolOutput;
203 .read_with(cx, |thread, _cx| thread.action_log().clone())?;
204 232
205 match input.mode {233 const NAME: &'static str = "streaming_edit_file";
206 StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
207 action_log.update(cx, |log, cx| {
208 log.buffer_created(buffer.clone(), cx);
209 });
210 let content = input.content.ok_or_else(|| {
211 anyhow!("'content' field is required for create and overwrite modes")
212 })?;
213 cx.update(|cx| {
214 buffer.update(cx, |buffer, cx| {
215 buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
216 });
217 action_log.update(cx, |log, cx| {
218 log.buffer_edited(buffer.clone(), cx);
219 });
220 });
221 }
222 StreamingEditFileMode::Edit => {
223 let edits = input
224 .edits
225 .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?;
226
227 let remaining_edits = &edits[remaining_edits_start_ix..];
228 apply_edits(
229 &buffer,
230 &action_log,
231 remaining_edits,
232 &diff,
233 event_stream,
234 &abs_path,
235 cx,
236 )?;
237 }
238 }
239 234
240 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {235 fn supports_input_streaming() -> bool {
241 let settings = language_settings::language_settings(236 true
242 buffer.language().map(|l| l.name()),237 }
243 buffer.file(),
244 cx,
245 );
246 settings.format_on_save != FormatOnSave::Off
247 });
248 238
249 if format_on_save_enabled {239 fn kind() -> acp::ToolKind {
250 action_log.update(cx, |log, cx| {240 acp::ToolKind::Edit
251 log.buffer_edited(buffer.clone(), cx);241 }
252 });
253 242
254 let format_task = tool.project.update(cx, |project, cx| {243 fn initial_title(
255 project.format(244 &self,
256 HashSet::from_iter([buffer.clone()]),245 input: Result<Self::Input, serde_json::Value>,
257 LspFormatTarget::Buffers,246 cx: &mut App,
258 false,247 ) -> SharedString {
259 FormatTrigger::Save,248 match input {
260 cx,249 Ok(input) => self
261 )250 .project
262 });251 .read(cx)
263 futures::select! {252 .find_project_path(&input.path, cx)
264 result = format_task.fuse() => { result.log_err(); },253 .and_then(|project_path| {
265 _ = event_stream.cancelled_by_user().fuse() => {254 self.project
266 anyhow::bail!("Edit cancelled by user");255 .read(cx)
256 .short_full_path_for_project_path(&project_path, cx)
257 })
258 .unwrap_or(input.path)
259 .into(),
260 Err(raw_input) => {
261 if let Some(input) =
262 serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()
263 {
264 let path = input.path.unwrap_or_default();
265 let path = path.trim();
266 if !path.is_empty() {
267 return self
268 .project
269 .read(cx)
270 .find_project_path(&path, cx)
271 .and_then(|project_path| {
272 self.project
273 .read(cx)
274 .short_full_path_for_project_path(&project_path, cx)
275 })
276 .unwrap_or_else(|| path.to_string())
277 .into();
267 }278 }
268 };
269 }
270 279
271 let save_task = tool280 let description = input.display_description.unwrap_or_default();
272 .project281 let description = description.trim();
273 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));282 if !description.is_empty() {
274 futures::select! {283 return description.to_string().into();
275 result = save_task.fuse() => { result?; },284 }
276 _ = event_stream.cancelled_by_user().fuse() => {
277 anyhow::bail!("Edit cancelled by user");
278 }285 }
279 };
280
281 action_log.update(cx, |log, cx| {
282 log.buffer_edited(buffer.clone(), cx);
283 });
284 286
285 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {287 DEFAULT_UI_TEXT.into()
286 buffer.file().and_then(|file| file.disk_state().mtime())
287 }) {
288 tool.thread.update(cx, |thread, _| {
289 thread
290 .file_read_times
291 .insert(abs_path.to_path_buf(), new_mtime);
292 })?;
293 }288 }
289 }
290 }
294 291
295 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());292 fn run(
296 let (new_text, unified_diff) = cx293 self: Arc<Self>,
297 .background_spawn({294 mut input: ToolInput<Self::Input>,
298 let new_snapshot = new_snapshot.clone();295 event_stream: ToolCallEventStream,
299 let old_text = old_text.clone();296 cx: &mut App,
300 async move {297 ) -> Task<Result<Self::Output, Self::Output>> {
301 let new_text = new_snapshot.text();298 cx.spawn(async move |cx: &mut AsyncApp| {
302 let diff = language::unified_diff(&old_text, &new_text);299 let mut state: Option<EditSession> = None;
303 (new_text, diff)300 loop {
301 futures::select! {
302 partial = input.recv_partial().fuse() => {
303 let Some(partial_value) = partial else { break };
304 if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
305 if state.is_none() && let Some(path_str) = &parsed.path
306 && let Some(display_description) = &parsed.display_description
307 && let Some(mode) = parsed.mode.clone() {
308 state = Some(
309 EditSession::new(
310 path_str,
311 display_description,
312 mode,
313 &self,
314 &event_stream,
315 cx,
316 )
317 .await?,
318 );
319 }
320
321 if let Some(state) = &mut state {
322 state.process(parsed, &self, &event_stream, cx)?;
323 }
324 }
304 }325 }
305 })326 _ = event_stream.cancelled_by_user().fuse() => {
306 .await;327 return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
328 }
329 }
330 }
331 let full_input =
332 input
333 .recv()
334 .await
335 .map_err(|e| StreamingEditFileToolOutput::error(format!("Failed to receive tool input: {e}")))?;
307 336
308 let output = StreamingEditFileToolOutput::Success {337 let mut state = if let Some(state) = state {
309 input_path: PathBuf::from(input.path),338 state
310 new_text,339 } else {
311 old_text: old_text.clone(),340 EditSession::new(
312 diff: unified_diff,341 &full_input.path,
342 &full_input.display_description,
343 full_input.mode.clone(),
344 &self,
345 &event_stream,
346 cx,
347 )
348 .await?
313 };349 };
314 Ok(output)350 state.finalize(full_input, &self, &event_stream, cx).await
315 }
316 .await;
317 result.map_err(|e| StreamingEditFileToolOutput::Error {
318 error: e.to_string(),
319 })351 })
320 }352 }
321 353
322 async fn process(354 fn replay(
323 &mut self,355 &self,
324 partial: StreamingEditFileToolPartialInput,356 _input: Self::Input,
325 tool: &StreamingEditFileTool,357 output: Self::Output,
326 event_stream: &ToolCallEventStream,358 event_stream: ToolCallEventStream,
327 cx: &mut AsyncApp,359 cx: &mut App,
328 ) -> Result<(), StreamingEditFileToolOutput> {360 ) -> Result<()> {
329 match self {361 match output {
330 Self::Idle => {362 StreamingEditFileToolOutput::Success {
331 if let Some(path_str) = partial.path363 input_path,
332 && let Some(display_description) = partial.display_description364 old_text,
333 && let Some(mode) = partial.mode365 new_text,
334 {366 ..
335 *self = Self::transition_to_buffer_resolved(367 } => {
336 &path_str,368 event_stream.update_diff(cx.new(|cx| {
337 &display_description,369 Diff::finalized(
338 mode,370 input_path.to_string_lossy().into_owned(),
339 tool,371 Some(old_text.to_string()),
340 event_stream,372 new_text,
373 self.language_registry.clone(),
341 cx,374 cx,
342 )375 )
343 .await?;376 }));
344 }377 Ok(())
345 }378 }
346 Self::BufferResolved {379 StreamingEditFileToolOutput::Error { .. } => Ok(()),
347 abs_path,380 }
348 buffer,381 }
349 edit_state,382}
350 diff,383
351 mode,384pub struct EditSession {
352 last_content_len,385 abs_path: PathBuf,
353 ..386 buffer: Entity<Buffer>,
354 } => match mode {387 old_text: Arc<String>,
355 StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {388 diff: Entity<Diff>,
356 if let Some(content) = &partial.content {389 mode: StreamingEditFileMode,
357 Self::process_streaming_content(390 parser: ToolEditParser,
358 buffer,391 pipeline: EditPipeline,
359 diff,392 _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
360 last_content_len,393}
361 content,394
362 cx,395struct EditPipeline {
363 )?;396 edits: Vec<EditPipelineEntry>,
364 }397 content_written: bool,
365 }398}
366 StreamingEditFileMode::Edit => {399
367 if let Some(edits) = partial.edits {400enum EditPipelineEntry {
368 Self::process_streaming_edits(401 ResolvingOldText {
369 buffer,402 matcher: StreamingFuzzyMatcher,
370 diff,403 },
371 edit_state,404 StreamingNewText {
372 &edits,405 streaming_diff: StreamingDiff,
373 abs_path,406 edit_cursor: usize,
374 tool,407 reindenter: Reindenter,
375 event_stream,408 original_snapshot: text::BufferSnapshot,
376 cx,409 },
377 )?;410 Done,
378 }411}
379 }412
380 },413impl EditPipeline {
414 fn new() -> Self {
415 Self {
416 edits: Vec::new(),
417 content_written: false,
381 }418 }
382 Ok(())
383 }419 }
384 420
385 async fn transition_to_buffer_resolved(421 fn ensure_resolving_old_text(
422 &mut self,
423 edit_index: usize,
424 buffer: &Entity<Buffer>,
425 cx: &mut AsyncApp,
426 ) {
427 while self.edits.len() <= edit_index {
428 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
429 self.edits.push(EditPipelineEntry::ResolvingOldText {
430 matcher: StreamingFuzzyMatcher::new(snapshot),
431 });
432 }
433 }
434}
435
436/// Compute the `LineIndent` of the first line in a set of query lines.
437fn query_first_line_indent(query_lines: &[String]) -> text::LineIndent {
438 let first_line = query_lines.first().map(|s| s.as_str()).unwrap_or("");
439 text::LineIndent::from_iter(first_line.chars())
440}
441
442impl EditSession {
443 async fn new(
386 path_str: &str,444 path_str: &str,
387 display_description: &str,445 display_description: &str,
388 mode: StreamingEditFileMode,446 mode: StreamingEditFileMode,
@@ -393,15 +451,13 @@ impl StreamingEditState { · 4 unmodified lines
393 let path = PathBuf::from(path_str);451 let path = PathBuf::from(path_str);
394 let project_path = cx452 let project_path = cx
395 .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx))453 .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx))
396 .map_err(|e| StreamingEditFileToolOutput::Error {454 .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
397 error: e.to_string(),
398 })?;
399 455
400 let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))456 let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
401 else {457 else {
402 return Err(StreamingEditFileToolOutput::Error {458 return Err(StreamingEditFileToolOutput::error(format!(
403 error: format!("File '{path_str}' does not exist"),459 "Worktree at '{path_str}' does not exist"
404 });460 )));
405 };461 };
406 462
407 event_stream.update_fields(463 event_stream.update_fields(
@@ -410,17 +466,13 @@ impl StreamingEditState { · 2 unmodified lines
410 466
411 cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))467 cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
412 .await468 .await
413 .map_err(|e| StreamingEditFileToolOutput::Error {469 .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
414 error: e.to_string(),
415 })?;
416 470
417 let buffer = tool471 let buffer = tool
418 .project472 .project
419 .update(cx, |project, cx| project.open_buffer(project_path, cx))473 .update(cx, |project, cx| project.open_buffer(project_path, cx))
420 .await474 .await
421 .map_err(|e| StreamingEditFileToolOutput::Error {475 .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
422 error: e.to_string(),
423 })?;
424 476
425 ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;477 ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
426 478
@@ -434,6 +486,14 @@ impl StreamingEditState { · 7 unmodified lines
434 }486 }
435 }) as Box<dyn FnOnce()>);487 }) as Box<dyn FnOnce()>);
436 488
489 tool.thread
490 .update(cx, |thread, cx| {
491 thread
492 .action_log()
493 .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx))
494 })
495 .ok();
496
437 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());497 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
438 let old_text = cx498 let old_text = cx
439 .background_spawn({499 .background_spawn({
@@ -442,205 +502,497 @@ impl StreamingEditState { · 2 unmodified lines
442 })502 })
443 .await;503 .await;
444 504
445 Ok(Self::BufferResolved {505 Ok(Self {
446 abs_path,506 abs_path,
447 buffer,507 buffer,
448 old_text,508 old_text,
449 diff,509 diff,
450 mode,510 mode,
451 last_content_len: 0,511 parser: ToolEditParser::default(),
452 edit_state: IncrementalEditState::default(),512 pipeline: EditPipeline::new(),
453 _finalize_diff_guard: finalize_diff_guard,513 _finalize_diff_guard: finalize_diff_guard,
454 })514 })
455 }515 }
456 516
457 fn process_streaming_content(517 async fn finalize(
458 buffer: &Entity<Buffer>,518 &mut self,
459 diff: &Entity<Diff>,519 input: StreamingEditFileToolInput,
460 last_content_len: &mut usize,520 tool: &StreamingEditFileTool,
461 content: &str,521 event_stream: &ToolCallEventStream,
462 cx: &mut AsyncApp,522 cx: &mut AsyncApp,
463 ) -> Result<(), StreamingEditFileToolOutput> {523 ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
464 let new_len = content.len();524 let Self {
465 if new_len > *last_content_len {525 buffer,
466 let new_chunk = &content[*last_content_len..];526 old_text,
467 cx.update(|cx| {527 diff,
468 buffer.update(cx, |buffer, cx| {528 abs_path,
469 // On the first update, replace the entire buffer (handles Overwrite529 parser,
470 // clearing existing content). For Create the buffer is already empty530 pipeline,
471 // so 0..0 is a no-op range prefix.531 ..
472 let insert_at = if *last_content_len == 0 {532 } = self;
473 0..buffer.len()533
474 } else {534 let action_log = tool
475 let len = buffer.len();535 .thread
476 len..len536 .read_with(cx, |thread, _cx| thread.action_log().clone())
477 };537 .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
478 buffer.edit([(insert_at, new_chunk)], None, cx);538
539 match input.mode {
540 StreamingEditFileMode::Write => {
541 action_log.update(cx, |log, cx| {
542 log.buffer_created(buffer.clone(), cx);
479 });543 });
544 let content = input.content.ok_or_else(|| {
545 StreamingEditFileToolOutput::error("'content' field is required for write mode")
546 })?;
547
548 let events = parser.finalize_content(&content);
549 Self::process_events(
550 &events,
551 buffer,
552 diff,
553 pipeline,
554 abs_path,
555 tool,
556 event_stream,
557 cx,
558 )?;
559 }
560 StreamingEditFileMode::Edit => {
561 let edits = input.edits.ok_or_else(|| {
562 StreamingEditFileToolOutput::error("'edits' field is required for edit mode")
563 })?;
564
565 let final_edits = edits
566 .into_iter()
567 .map(|e| Edit {
568 old_text: e.old_text,
569 new_text: e.new_text,
570 })
571 .collect::<Vec<_>>();
572 let events = parser.finalize_edits(&final_edits);
573 Self::process_events(
574 &events,
575 buffer,
576 diff,
577 pipeline,
578 abs_path,
579 tool,
580 event_stream,
581 cx,
582 )?;
583 }
584 }
585
586 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
587 let settings = language_settings::language_settings(
588 buffer.language().map(|l| l.name()),
589 buffer.file(),
590 cx,
591 );
592 settings.format_on_save != FormatOnSave::Off
593 });
594
595 if format_on_save_enabled {
596 action_log.update(cx, |log, cx| {
597 log.buffer_edited(buffer.clone(), cx);
480 });598 });
481 *last_content_len = new_len;
482 599
483 let anchor_range = buffer.read_with(cx, |buffer, _cx| {600 let format_task = tool.project.update(cx, |project, cx| {
484 buffer.anchor_range_between(0..buffer.len())601 project.format(
602 HashSet::from_iter([buffer.clone()]),
603 LspFormatTarget::Buffers,
604 false,
605 FormatTrigger::Save,
606 cx,
607 )
485 });608 });
486 diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));609 futures::select! {
610 result = format_task.fuse() => { result.log_err(); },
611 _ = event_stream.cancelled_by_user().fuse() => {
612 return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
613 }
614 };
615 }
616
617 let save_task = tool
618 .project
619 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
620 futures::select! {
621 result = save_task.fuse() => { result.map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; },
622 _ = event_stream.cancelled_by_user().fuse() => {
623 return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
624 }
625 };
626
627 action_log.update(cx, |log, cx| {
628 log.buffer_edited(buffer.clone(), cx);
629 });
630
631 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
632 buffer.file().and_then(|file| file.disk_state().mtime())
633 }) {
634 tool.thread
635 .update(cx, |thread, _| {
636 thread
637 .file_read_times
638 .insert(abs_path.to_path_buf(), new_mtime);
639 })
640 .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
641 }
642
643 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
644 let (new_text, unified_diff) = cx
645 .background_spawn({
646 let new_snapshot = new_snapshot.clone();
647 let old_text = old_text.clone();
648 async move {
649 let new_text = new_snapshot.text();
650 let diff = language::unified_diff(&old_text, &new_text);
651 (new_text, diff)
652 }
653 })
654 .await;
655
656 let output = StreamingEditFileToolOutput::Success {
657 input_path: PathBuf::from(input.path),
658 new_text,
659 old_text: old_text.clone(),
660 diff: unified_diff,
661 };
662 Ok(output)
663 }
664
665 fn process(
666 &mut self,
667 partial: StreamingEditFileToolPartialInput,
668 tool: &StreamingEditFileTool,
669 event_stream: &ToolCallEventStream,
670 cx: &mut AsyncApp,
671 ) -> Result<(), StreamingEditFileToolOutput> {
672 match &self.mode {
673 StreamingEditFileMode::Write => {
674 if let Some(content) = &partial.content {
675 let events = self.parser.push_content(content);
676 Self::process_events(
677 &events,
678 &self.buffer,
679 &self.diff,
680 &mut self.pipeline,
681 &self.abs_path,
682 tool,
683 event_stream,
684 cx,
685 )?;
686 }
687 }
688 StreamingEditFileMode::Edit => {
689 if let Some(edits) = partial.edits {
690 let events = self.parser.push_edits(&edits);
691 Self::process_events(
692 &events,
693 &self.buffer,
694 &self.diff,
695 &mut self.pipeline,
696 &self.abs_path,
697 tool,
698 event_stream,
699 cx,
700 )?;
701 }
702 }
487 }703 }
488 Ok(())704 Ok(())
489 }705 }
490 706
491 fn process_streaming_edits(707 fn process_events(
708 events: &[ToolEditEvent],
492 buffer: &Entity<Buffer>,709 buffer: &Entity<Buffer>,
493 diff: &Entity<Diff>,710 diff: &Entity<Diff>,
494 edit_state: &mut IncrementalEditState,711 pipeline: &mut EditPipeline,
495 edits: &[PartialEditOperation],
496 abs_path: &PathBuf,712 abs_path: &PathBuf,
497 tool: &StreamingEditFileTool,713 tool: &StreamingEditFileTool,
498 event_stream: &ToolCallEventStream,714 event_stream: &ToolCallEventStream,
499 cx: &mut AsyncApp,715 cx: &mut AsyncApp,
500 ) -> Result<(), StreamingEditFileToolOutput> {716 ) -> Result<(), StreamingEditFileToolOutput> {
501 if edits.is_empty() {717 for event in events {
502 return Ok(());718 match event {
503 }719 ToolEditEvent::ContentChunk { chunk } => {
720 cx.update(|cx| {
721 buffer.update(cx, |buffer, cx| {
722 let insert_at = if !pipeline.content_written && buffer.len() > 0 {
723 0..buffer.len()
724 } else {
725 let len = buffer.len();
726 len..len
727 };
728 buffer.edit([(insert_at, chunk.as_str())], None, cx);
729 });
730 let buffer_id = buffer.read(cx).remote_id();
731 tool.set_agent_location(
732 buffer.downgrade(),
733 text::Anchor::max_for_buffer(buffer_id),
734 cx,
735 );
736 });
737 pipeline.content_written = true;
738 }
504 739
505 // Edits at indices applied_count..edits.len()-1 are newly complete740 ToolEditEvent::OldTextChunk {
506 // (a subsequent edit exists, proving the LLM moved on).741 edit_index,
507 // The last edit (edits.len()-1) is potentially still in progress.742 chunk,
508 let completed_count = edits.len().saturating_sub(1);743 done: false,
744 } => {
745 pipeline.ensure_resolving_old_text(*edit_index, buffer, cx);
746
747 if let EditPipelineEntry::ResolvingOldText { matcher } =
748 &mut pipeline.edits[*edit_index]
749 {
750 if !chunk.is_empty() {
751 if let Some(match_range) = matcher.push(chunk, None) {
752 let anchor_range = buffer.read_with(cx, |buffer, _cx| {
753 buffer.anchor_range_between(match_range.clone())
754 });
755 diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
756
757 cx.update(|cx| {
758 let position = buffer.read(cx).anchor_before(match_range.end);
759 tool.set_agent_location(buffer.downgrade(), position, cx);
760 });
761 }
762 }
763 }
764 }
509 765
510 // Apply newly-complete edits766 ToolEditEvent::OldTextChunk {
511 while edit_state.applied_count() < completed_count {767 edit_index,
512 let edit_index = edit_state.applied_count();768 chunk,
513 let partial_edit = &edits[edit_index];769 done: true,
770 } => {
771 pipeline.ensure_resolving_old_text(*edit_index, buffer, cx);
772
773 let EditPipelineEntry::ResolvingOldText { matcher } =
774 &mut pipeline.edits[*edit_index]
775 else {
776 continue;
777 };
514 778
515 let old_text = partial_edit.old_text.clone().ok_or_else(|| {779 if !chunk.is_empty() {
516 StreamingEditFileToolOutput::Error {780 matcher.push(chunk, None);
517 error: format!("Edit at index {} is missing old_text.", edit_index),781 }
518 }782 let matches = matcher.finish();
519 })?;783
520 let new_text = partial_edit.new_text.clone().unwrap_or_default();784 if matches.is_empty() {
785 return Err(StreamingEditFileToolOutput::error(format!(
786 "Could not find matching text for edit at index {}. \
787 The old_text did not match any content in the file. \
788 Please read the file again to get the current content.",
789 edit_index,
790 )));
791 }
792 if matches.len() > 1 {
793 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
794 let lines = matches
795 .iter()
796 .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
797 .collect::<Vec<_>>()
798 .join(", ");
799 return Err(StreamingEditFileToolOutput::error(format!(
800 "Edit {} matched multiple locations in the file at lines: {}. \
801 Please provide more context in old_text to uniquely \
802 identify the location.",
803 edit_index, lines
804 )));
805 }
521 806
522 edit_state.in_progress_matcher = None;807 let range = matches.into_iter().next().expect("checked len above");
523 edit_state.last_old_text_len = 0;
524 808
525 let edit_op = EditOperation {809 let anchor_range = buffer
526 old_text: old_text.clone(),810 .read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
527 new_text: new_text.clone(),811 diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
528 };812
813 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
529 814
530 let action_log = tool815 let line = snapshot.offset_to_point(range.start).row;
531 .thread816 event_stream.update_fields(
532 .read_with(cx, |thread, _cx| thread.action_log().clone())817 ToolCallUpdateFields::new()
533 .ok();818 .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
819 );
820
821 let EditPipelineEntry::ResolvingOldText { matcher } =
822 &pipeline.edits[*edit_index]
823 else {
824 continue;
825 };
826 let buffer_indent =
827 snapshot.line_indent_for_row(snapshot.offset_to_point(range.start).row);
828 let query_indent = query_first_line_indent(matcher.query_lines());
829 let indent_delta = compute_indent_delta(buffer_indent, query_indent);
830
831 let old_text_in_buffer =
832 snapshot.text_for_range(range.clone()).collect::<String>();
833
834 let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
835 pipeline.edits[*edit_index] = EditPipelineEntry::StreamingNewText {
836 streaming_diff: StreamingDiff::new(old_text_in_buffer),
837 edit_cursor: range.start,
838 reindenter: Reindenter::new(indent_delta),
839 original_snapshot: text_snapshot,
840 };
534 841
535 // On the first edit, mark the buffer as read842 cx.update(|cx| {
536 if edit_state.applied_count() == 0 {843 let position = buffer.read(cx).anchor_before(range.end);
537 if let Some(action_log) = &action_log {844 tool.set_agent_location(buffer.downgrade(), position, cx);
538 action_log.update(cx, |log, cx| {
539 log.buffer_read(buffer.clone(), cx);
540 });845 });
541 }846 }
542 }
543 847
544 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());848 ToolEditEvent::NewTextChunk {
545 849 edit_index,
546 let (range, new_text) =850 chunk,
547 match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) {851 done: false,
548 Ok(resolved) => resolved,852 } => {
549 Err(EditResolveError::NotFound) => {853 if *edit_index >= pipeline.edits.len() {
550 return Err(StreamingEditFileToolOutput::Error {854 continue;
551 error: format!(
552 "Could not find matching text for edit at index {}. \
553 The old_text did not match any content in the file. \
554 Please read the file again to get the current content.",
555 edit_index
556 ),
557 });
558 }855 }
559 Err(EditResolveError::Ambiguous(ranges)) => {856 let EditPipelineEntry::StreamingNewText {
560 let lines = ranges857 streaming_diff,
561 .iter()858 edit_cursor,
562 .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())859 reindenter,
563 .collect::<Vec<_>>()860 original_snapshot,
564 .join(", ");861 ..
565 return Err(StreamingEditFileToolOutput::Error {862 } = &mut pipeline.edits[*edit_index]
566 error: format!(863 else {
567 "Edit {} matched multiple locations in the file at lines: {}. \864 continue;
568 Please provide more context in old_text to uniquely \865 };
569 identify the location.",866
570 edit_index, lines867 let reindented = reindenter.push(chunk);
571 ),868 if reindented.is_empty() {
572 });869 continue;
573 }870 }
574 };
575 871
576 for previous_range in &edit_state.applied_ranges {872 let char_ops = streaming_diff.push_new(&reindented);
577 let previous_start = previous_range.start.to_offset(&snapshot);873 Self::apply_char_operations(
578 let previous_end = previous_range.end.to_offset(&snapshot);874 &char_ops,
579 if range.start < previous_end && previous_start < range.end {875 buffer,
580 let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1;876 original_snapshot,
581 let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1;877 edit_cursor,
582 let later_start_line = snapshot.offset_to_point(range.start).row + 1;878 cx,
583 let later_end_line = snapshot.offset_to_point(range.end).row + 1;879 );
584 return Err(StreamingEditFileToolOutput::Error {880
585 error: format!(881 let position = original_snapshot.anchor_before(*edit_cursor);
586 "Conflicting edit ranges detected: lines {}-{} \882 cx.update(|cx| {
587 conflicts with lines {}-{}. Conflicting edit \883 tool.set_agent_location(buffer.downgrade(), position, cx);
588 ranges are not allowed, as they would overwrite \
589 each other.",
590 earlier_start_line, earlier_end_line, later_start_line, later_end_line,
591 ),
592 });884 });
885
886 let action_log = tool
887 .thread
888 .read_with(cx, |thread, _cx| thread.action_log().clone())
889 .ok();
890 if let Some(action_log) = action_log {
891 action_log.update(cx, |log, cx| {
892 log.buffer_edited(buffer.clone(), cx);
893 });
894 }
593 }895 }
594 }
595 896
596 let anchor_range =897 ToolEditEvent::NewTextChunk {
597 buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));898 edit_index,
598 edit_state.applied_ranges.push(anchor_range);899 chunk,
900 done: true,
901 } => {
902 if *edit_index >= pipeline.edits.len() {
903 continue;
904 }
599 905
600 let line = snapshot.offset_to_point(range.start).row;906 let EditPipelineEntry::StreamingNewText {
601 event_stream.update_fields(907 mut streaming_diff,
602 ToolCallUpdateFields::new()908 mut edit_cursor,
603 .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),909 mut reindenter,
604 );910 original_snapshot,
911 } = std::mem::replace(
912 &mut pipeline.edits[*edit_index],
913 EditPipelineEntry::Done,
914 )
915 else {
916 continue;
917 };
605 918
606 if let Some(action_log) = action_log {919 // Flush any remaining reindent buffer + final chunk.
607 cx.update(|cx| {920 let mut final_text = reindenter.push(chunk);
608 buffer.update(cx, |buffer, cx| {921 final_text.push_str(&reindenter.finish());
609 buffer.edit([(range, new_text.as_str())], None, cx);
610 });
611 action_log.update(cx, |log, cx| {
612 log.buffer_edited(buffer.clone(), cx);
613 });
614 });
615 }
616 }
617 922
618 // Feed the in-progress last edit's old_text to the matcher for live preview923 if !final_text.is_empty() {
619 if let Some(partial_edit) = edits.last() {924 let char_ops = streaming_diff.push_new(&final_text);
620 if let Some(old_text) = &partial_edit.old_text {925 Self::apply_char_operations(
621 let old_text_len = old_text.len();926 &char_ops,
622 if old_text_len > edit_state.last_old_text_len {927 buffer,
623 let new_chunk = &old_text[edit_state.last_old_text_len..];928 &original_snapshot,
929 &mut edit_cursor,
930 cx,
931 );
932 }
933
934 let remaining_ops = streaming_diff.finish();
935 Self::apply_char_operations(
936 &remaining_ops,
937 buffer,
938 &original_snapshot,
939 &mut edit_cursor,
940 cx,
941 );
624 942
625 let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| {943 let position = original_snapshot.anchor_before(edit_cursor);
626 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());944 cx.update(|cx| {
627 StreamingFuzzyMatcher::new(snapshot)945 tool.set_agent_location(buffer.downgrade(), position, cx);
628 });946 });
629 947
630 if let Some(match_range) = matcher.push(new_chunk, None) {948 let action_log = tool
631 let anchor_range = buffer.read_with(cx, |buffer, _cx| {949 .thread
632 buffer.anchor_range_between(match_range.clone())950 .read_with(cx, |thread, _cx| thread.action_log().clone())
951 .ok();
952 if let Some(action_log) = action_log {
953 action_log.update(cx, |log, cx| {
954 log.buffer_edited(buffer.clone(), cx);
633 });955 });
634 diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
635 }956 }
636
637 edit_state.last_old_text_len = old_text_len;
638 }957 }
639 }958 }
640 }959 }
641
642 Ok(())960 Ok(())
643 }961 }
962
963 fn apply_char_operations(
964 ops: &[CharOperation],
965 buffer: &Entity<Buffer>,
966 snapshot: &text::BufferSnapshot,
967 edit_cursor: &mut usize,
968 cx: &mut AsyncApp,
969 ) {
970 for op in ops {
971 match op {
972 CharOperation::Insert { text } => {
973 let anchor = snapshot.anchor_after(*edit_cursor);
974 cx.update(|cx| {
975 buffer.update(cx, |buffer, cx| {
976 buffer.edit([(anchor..anchor, text.as_str())], None, cx);
977 });
978 });
979 }
980 CharOperation::Delete { bytes } => {
981 let delete_end = *edit_cursor + bytes;
982 let anchor_range = snapshot.anchor_range_around(*edit_cursor..delete_end);
983 cx.update(|cx| {
984 buffer.update(cx, |buffer, cx| {
985 buffer.edit([(anchor_range, "")], None, cx);
986 });
987 });
988 *edit_cursor = delete_end;
989 }
990 CharOperation::Keep { bytes } => {
991 *edit_cursor += bytes;
992 }
993 }
994 }
995 }
644}996}
645 997
646fn ensure_buffer_saved(998fn ensure_buffer_saved(
@@ -670,396 +1022,40 @@ fn ensure_buffer_saved( · 23 unmodified lines
670 if is_dirty {1022 if is_dirty {
671 let message = match (has_save_tool, has_restore_tool) {1023 let message = match (has_save_tool, has_restore_tool) {
672 (true, true) => {1024 (true, true) => {
673 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \1025 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
674 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \1026 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
675 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."1027 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."
676 }
677 (true, false) => {
678 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
679 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
680 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
681 }
682 (false, true) => {
683 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
684 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
685 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."
686 }
687 (false, false) => {
688 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
689 then ask them to save or revert the file manually and inform you when it's ok to proceed."
690 }
691 };
692 return Err(StreamingEditFileToolOutput::Error {
693 error: message.to_string(),
694 });
695 }
696
697 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
698 if current != last_read {
699 return Err(StreamingEditFileToolOutput::Error {
700 error: "The file has been modified since you last read it. \
701 Please read the file again to get the current state before editing it."
702 .to_string(),
703 });
704 }
705 }
706
707 Ok(())
708}
709
710#[derive(Debug, Serialize, Deserialize)]
711#[serde(untagged)]
712pub enum StreamingEditFileToolOutput {
713 Success {
714 #[serde(alias = "original_path")]
715 input_path: PathBuf,
716 new_text: String,
717 old_text: Arc<String>,
718 #[serde(default)]
719 diff: String,
720 },
721 Error {
722 error: String,
723 },
724}
725
726impl std::fmt::Display for StreamingEditFileToolOutput {
727 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
728 match self {
729 StreamingEditFileToolOutput::Success {
730 diff, input_path, ..
731 } => {
732 if diff.is_empty() {
733 write!(f, "No edits were made.")
734 } else {
735 write!(
736 f,
737 "Edited {}:\n\n```diff\n{diff}\n```",
738 input_path.display()
739 )
740 }
741 }
742 StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),
743 }
744 }
745}
746
747impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
748 fn from(output: StreamingEditFileToolOutput) -> Self {
749 output.to_string().into()
750 }
751}
752
753pub struct StreamingEditFileTool {
754 thread: WeakEntity<Thread>,
755 language_registry: Arc<LanguageRegistry>,
756 project: Entity<Project>,
757}
758
759impl StreamingEditFileTool {
760 pub fn new(
761 project: Entity<Project>,
762 thread: WeakEntity<Thread>,
763 language_registry: Arc<LanguageRegistry>,
764 ) -> Self {
765 Self {
766 project,
767 thread,
768 language_registry,
769 }
770 }
771
772 fn authorize(
773 &self,
774 path: &PathBuf,
775 description: &str,
776 event_stream: &ToolCallEventStream,
777 cx: &mut App,
778 ) -> Task<Result<()>> {
779 super::tool_permissions::authorize_file_edit(
780 EditFileTool::NAME,
781 path,
782 description,
783 &self.thread,
784 event_stream,
785 cx,
786 )
787 }
788}
789
790impl AgentTool for StreamingEditFileTool {
791 type Input = StreamingEditFileToolInput;
792 type Output = StreamingEditFileToolOutput;
793
794 const NAME: &'static str = "streaming_edit_file";
795
796 fn supports_input_streaming() -> bool {
797 true
798 }
799
800 fn kind() -> acp::ToolKind {
801 acp::ToolKind::Edit
802 }
803
804 fn initial_title(
805 &self,
806 input: Result<Self::Input, serde_json::Value>,
807 cx: &mut App,
808 ) -> SharedString {
809 match input {
810 Ok(input) => self
811 .project
812 .read(cx)
813 .find_project_path(&input.path, cx)
814 .and_then(|project_path| {
815 self.project
816 .read(cx)
817 .short_full_path_for_project_path(&project_path, cx)
818 })
819 .unwrap_or(input.path)
820 .into(),
821 Err(raw_input) => {
822 if let Some(input) =
823 serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()
824 {
825 let path = input.path.unwrap_or_default();
826 let path = path.trim();
827 if !path.is_empty() {
828 return self
829 .project
830 .read(cx)
831 .find_project_path(&path, cx)
832 .and_then(|project_path| {
833 self.project
834 .read(cx)
835 .short_full_path_for_project_path(&project_path, cx)
836 })
837 .unwrap_or_else(|| path.to_string())
838 .into();
839 }
840
841 let description = input.display_description.unwrap_or_default();
842 let description = description.trim();
843 if !description.is_empty() {
844 return description.to_string().into();
845 }
846 }
847
848 DEFAULT_UI_TEXT.into()
849 }
850 }
851 }
852
853 fn run(
854 self: Arc<Self>,
855 mut input: ToolInput<Self::Input>,
856 event_stream: ToolCallEventStream,
857 cx: &mut App,
858 ) -> Task<Result<Self::Output, Self::Output>> {
859 cx.spawn(async move |cx: &mut AsyncApp| {
860 let mut state = StreamingEditState::Idle;
861 loop {
862 futures::select! {
863 partial = input.recv_partial().fuse() => {
864 let Some(partial_value) = partial else { break };
865 if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
866 state.process(parsed, &self, &event_stream, cx).await?;
867 }
868 }
869 _ = event_stream.cancelled_by_user().fuse() => {
870 return Err(StreamingEditFileToolOutput::Error {
871 error: "Edit cancelled by user".to_string(),
872 });
873 }
874 }
875 }
876 let full_input =
877 input
878 .recv()
879 .await
880 .map_err(|e| StreamingEditFileToolOutput::Error {
881 error: format!("Failed to receive tool input: {e}"),
882 })?;
883
884 state.finalize(full_input, &self, &event_stream, cx).await
885 })
886 }
887
888 fn replay(
889 &self,
890 _input: Self::Input,
891 output: Self::Output,
892 event_stream: ToolCallEventStream,
893 cx: &mut App,
894 ) -> Result<()> {
895 match output {
896 StreamingEditFileToolOutput::Success {
897 input_path,
898 old_text,
899 new_text,
900 ..
901 } => {
902 event_stream.update_diff(cx.new(|cx| {
903 Diff::finalized(
904 input_path.to_string_lossy().into_owned(),
905 Some(old_text.to_string()),
906 new_text,
907 self.language_registry.clone(),
908 cx,
909 )
910 }));
911 Ok(())
912 }
913 StreamingEditFileToolOutput::Error { .. } => Ok(()),
914 }
915 }
916}
917
918fn apply_edits(
919 buffer: &Entity<language::Buffer>,
920 action_log: &Entity<action_log::ActionLog>,
921 edits: &[EditOperation],
922 diff: &Entity<Diff>,
923 event_stream: &ToolCallEventStream,
924 abs_path: &PathBuf,
925 cx: &mut AsyncApp,
926) -> Result<()> {
927 let mut failed_edits = Vec::new();
928 let mut ambiguous_edits = Vec::new();
929 let mut resolved_edits: Vec<(Range<usize>, String)> = Vec::new();
930
931 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
932 for (index, edit) in edits.iter().enumerate() {
933 match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) {
934 Ok((range, new_text)) => {
935 resolved_edits.push((range, new_text));
936 }1028 }
937 Err(EditResolveError::NotFound) => {1029 (true, false) => {
938 failed_edits.push(index);1030 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1031 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
1032 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
939 }1033 }
940 Err(EditResolveError::Ambiguous(ranges)) => {1034 (false, true) => {
941 ambiguous_edits.push((index, ranges));1035 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1036 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
1037 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."
942 }1038 }
943 }1039 (false, false) => {
944 }1040 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
945 1041 then ask them to save or revert the file manually and inform you when it's ok to proceed."
946 if !failed_edits.is_empty() {1042 }
947 let indices = failed_edits1043 };
948 .iter()1044 return Err(StreamingEditFileToolOutput::error(message));
949 .map(|i| i.to_string())
950 .collect::<Vec<_>>()
951 .join(", ");
952 anyhow::bail!(
953 "Could not find matching text for edit(s) at index(es): {}. \
954 The old_text did not match any content in the file. \
955 Please read the file again to get the current content.",
956 indices
957 );
958 }
959
960 if !ambiguous_edits.is_empty() {
961 let details: Vec<String> = ambiguous_edits
962 .iter()
963 .map(|(index, ranges)| {
964 let lines = ranges
965 .iter()
966 .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
967 .collect::<Vec<_>>()
968 .join(", ");
969 format!("edit {}: matches at lines {}", index, lines)
970 })
971 .collect();
972 anyhow::bail!(
973 "Some edits matched multiple locations in the file:\n{}. \
974 Please provide more context in old_text to uniquely identify the location.",
975 details.join("\n")
976 );
977 }
978
979 let mut edits_sorted = resolved_edits;
980 edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start));
981
982 if let Some((first_range, _)) = edits_sorted.first() {
983 let line = snapshot.offset_to_point(first_range.start).row;
984 event_stream.update_fields(
985 ToolCallUpdateFields::new()
986 .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
987 );
988 }1045 }
989 1046
990 for window in edits_sorted.windows(2) {1047 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
991 if let [(earlier_range, _), (later_range, _)] = window1048 if current != last_read {
992 && (earlier_range.end > later_range.start || earlier_range.start == later_range.start)1049 return Err(StreamingEditFileToolOutput::error(
993 {1050 "The file has been modified since you last read it. \
994 let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1;1051 Please read the file again to get the current state before editing it.",
995 let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1;1052 ));
996 let later_start_line = snapshot.offset_to_point(later_range.start).row + 1;
997 let later_end_line = snapshot.offset_to_point(later_range.end).row + 1;
998 anyhow::bail!(
999 "Conflicting edit ranges detected: lines {}-{} conflicts with lines {}-{}. \
1000 Conflicting edit ranges are not allowed, as they would overwrite each other.",
1001 earlier_start_line,
1002 earlier_end_line,
1003 later_start_line,
1004 later_end_line,
1005 );
1006 }1053 }
1007 }1054 }
1008 1055
1009 if !edits_sorted.is_empty() {
1010 cx.update(|cx| {
1011 buffer.update(cx, |buffer, cx| {
1012 buffer.edit(
1013 edits_sorted
1014 .iter()
1015 .map(|(range, new_text)| (range.clone(), new_text.as_str())),
1016 None,
1017 cx,
1018 );
1019 });
1020 action_log.update(cx, |log, cx| {
1021 log.buffer_edited(buffer.clone(), cx);
1022 });
1023 });
1024 }
1025
1026 Ok(())1056 Ok(())
1027}1057}
1028 1058
1029enum EditResolveError {
1030 NotFound,
1031 Ambiguous(Vec<Range<usize>>),
1032}
1033
1034/// Resolves an edit operation by finding matching text in the buffer,
1035/// reveals the matched range in the diff view, and returns the resolved
1036/// range and replacement text.
1037fn resolve_and_reveal_edit(
1038 buffer: &Entity<Buffer>,
1039 diff: &Entity<Diff>,
1040 snapshot: &BufferSnapshot,
1041 edit: &EditOperation,
1042 cx: &mut AsyncApp,
1043) -> std::result::Result<(Range<usize>, String), EditResolveError> {
1044 let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
1045 matcher.push(&edit.old_text, None);
1046 let matches = matcher.finish();
1047 if matches.is_empty() {
1048 return Err(EditResolveError::NotFound);
1049 }
1050 if matches.len() > 1 {
1051 return Err(EditResolveError::Ambiguous(matches));
1052 }
1053
1054 let range = matches.into_iter().next().expect("checked len above");
1055
1056 let anchor_range =
1057 buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
1058 diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
1059
1060 Ok((range, edit.new_text.clone()))
1061}
1062
1063fn resolve_path(1059fn resolve_path(
1064 mode: StreamingEditFileMode,1060 mode: StreamingEditFileMode,
1065 path: &PathBuf,1061 path: &PathBuf,
@@ -1069,7 +1065,7 @@ fn resolve_path( · 3 unmodified lines
1069 let project = project.read(cx);1065 let project = project.read(cx);
1070 1066
1071 match mode {1067 match mode {
1072 StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => {1068 StreamingEditFileMode::Edit => {
1073 let path = project1069 let path = project
1074 .find_project_path(&path, cx)1070 .find_project_path(&path, cx)
1075 .context("Can't edit file: path not found")?;1071 .context("Can't edit file: path not found")?;
@@ -1081,13 +1077,12 @@ fn resolve_path( · 5 unmodified lines
1081 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");1077 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
1082 Ok(path)1078 Ok(path)
1083 }1079 }
1084 1080 StreamingEditFileMode::Write => {
1085 StreamingEditFileMode::Create => {1081 if let Some(path) = project.find_project_path(&path, cx)
1086 if let Some(path) = project.find_project_path(&path, cx) {1082 && let Some(entry) = project.entry_for_path(&path, cx)
1087 anyhow::ensure!(1083 {
1088 project.entry_for_path(&path, cx).is_none(),1084 anyhow::ensure!(entry.is_file(), "Can't write to file: path is a directory");
1089 "Can't create file: file already exists"1085 return Ok(path);
1090 );
1091 }1086 }
1092 1087
1093 let parent_path = path.parent().context("Can't create file: incorrect path")?;1088 let parent_path = path.parent().context("Can't create file: incorrect path")?;
@@ -1162,7 +1157,7 @@ mod tests { · 68 unmodified lines
1162 let input = StreamingEditFileToolInput {1157 let input = StreamingEditFileToolInput {
1163 display_description: "Create new file".into(),1158 display_description: "Create new file".into(),
1164 path: "root/dir/new_file.txt".into(),1159 path: "root/dir/new_file.txt".into(),
1165 mode: StreamingEditFileMode::Create,1160 mode: StreamingEditFileMode::Write,
1166 content: Some("Hello, World!".into()),1161 content: Some("Hello, World!".into()),
1167 edits: None,1162 edits: None,
1168 };1163 };
@@ -1214,7 +1209,7 @@ mod tests { · 45 unmodified lines
1214 let input = StreamingEditFileToolInput {1209 let input = StreamingEditFileToolInput {
1215 display_description: "Overwrite file".into(),1210 display_description: "Overwrite file".into(),
1216 path: "root/file.txt".into(),1211 path: "root/file.txt".into(),
1217 mode: StreamingEditFileMode::Overwrite,1212 mode: StreamingEditFileMode::Write,
1218 content: Some("new content".into()),1213 content: Some("new content".into()),
1219 edits: None,1214 edits: None,
1220 };1215 };
@@ -1276,7 +1271,7 @@ mod tests { · 55 unmodified lines
1276 path: "root/file.txt".into(),1271 path: "root/file.txt".into(),
1277 mode: StreamingEditFileMode::Edit,1272 mode: StreamingEditFileMode::Edit,
1278 content: None,1273 content: None,
1279 edits: Some(vec![EditOperation {1274 edits: Some(vec![Edit {
1280 old_text: "line 2".into(),1275 old_text: "line 2".into(),
1281 new_text: "modified line 2".into(),1276 new_text: "modified line 2".into(),
1282 }]),1277 }]),
@@ -1301,7 +1296,7 @@ mod tests { · 18 unmodified lines
1301 }1296 }
1302 1297
1303 #[gpui::test]1298 #[gpui::test]
1304 async fn test_streaming_edit_multiple_nonoverlapping_edits(cx: &mut TestAppContext) {1299 async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) {
1305 init_test(cx);1300 init_test(cx);
1306 1301
1307 let fs = project::FakeFs::new(cx.executor());1302 let fs = project::FakeFs::new(cx.executor());
@@ -1336,11 +1331,11 @@ mod tests { · 28 unmodified lines
1336 mode: StreamingEditFileMode::Edit,1331 mode: StreamingEditFileMode::Edit,
1337 content: None,1332 content: None,
1338 edits: Some(vec![1333 edits: Some(vec![
1339 EditOperation {1334 Edit {
1340 old_text: "line 5".into(),1335 old_text: "line 5".into(),
1341 new_text: "modified line 5".into(),1336 new_text: "modified line 5".into(),
1342 },1337 },
1343 EditOperation {1338 Edit {
1344 old_text: "line 1".into(),1339 old_text: "line 1".into(),
1345 new_text: "modified line 1".into(),1340 new_text: "modified line 1".into(),
1346 },1341 },
@@ -1404,11 +1399,11 @@ mod tests { · 57 unmodified lines
1404 mode: StreamingEditFileMode::Edit,1399 mode: StreamingEditFileMode::Edit,
1405 content: None,1400 content: None,
1406 edits: Some(vec![1401 edits: Some(vec![
1407 EditOperation {1402 Edit {
1408 old_text: "line 2".into(),1403 old_text: "line 2".into(),
1409 new_text: "modified line 2".into(),1404 new_text: "modified line 2".into(),
1410 },1405 },
1411 EditOperation {1406 Edit {
1412 old_text: "line 3".into(),1407 old_text: "line 3".into(),
1413 new_text: "modified line 3".into(),1408 new_text: "modified line 3".into(),
1414 },1409 },
@@ -1472,11 +1467,11 @@ mod tests { · 57 unmodified lines
1472 mode: StreamingEditFileMode::Edit,1467 mode: StreamingEditFileMode::Edit,
1473 content: None,1468 content: None,
1474 edits: Some(vec![1469 edits: Some(vec![
1475 EditOperation {1470 Edit {
1476 old_text: "line 1".into(),1471 old_text: "line 1".into(),
1477 new_text: "modified line 1".into(),1472 new_text: "modified line 1".into(),
1478 },1473 },
1479 EditOperation {1474 Edit {
1480 old_text: "line 5".into(),1475 old_text: "line 5".into(),
1481 new_text: "modified line 5".into(),1476 new_text: "modified line 5".into(),
1482 },1477 },
@@ -1533,7 +1528,7 @@ mod tests { · 50 unmodified lines
1533 path: "root/nonexistent_file.txt".into(),1528 path: "root/nonexistent_file.txt".into(),
1534 mode: StreamingEditFileMode::Edit,1529 mode: StreamingEditFileMode::Edit,
1535 content: None,1530 content: None,
1536 edits: Some(vec![EditOperation {1531 edits: Some(vec![Edit {
1537 old_text: "foo".into(),1532 old_text: "foo".into(),
1538 new_text: "bar".into(),1533 new_text: "bar".into(),
1539 }]),1534 }]),
@@ -1587,7 +1582,7 @@ mod tests { · 47 unmodified lines
1587 path: "root/file.txt".into(),1582 path: "root/file.txt".into(),
1588 mode: StreamingEditFileMode::Edit,1583 mode: StreamingEditFileMode::Edit,
1589 content: None,1584 content: None,
1590 edits: Some(vec![EditOperation {1585 edits: Some(vec![Edit {
1591 old_text: "nonexistent text that is not in the file".into(),1586 old_text: "nonexistent text that is not in the file".into(),
1592 new_text: "replacement".into(),1587 new_text: "replacement".into(),
1593 }]),1588 }]),
@@ -1614,79 +1609,6 @@ mod tests { · 20 unmodified lines
1614 );1609 );
1615 }1610 }
1616 1611
1617 #[gpui::test]
1618 async fn test_streaming_edit_overlapping_edits_out_of_order(cx: &mut TestAppContext) {
1619 init_test(cx);
1620
1621 let fs = project::FakeFs::new(cx.executor());
1622 // Multi-line file so the line-based fuzzy matcher can resolve each edit.
1623 fs.insert_tree(
1624 "/root",
1625 json!({
1626 "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"
1627 }),
1628 )
1629 .await;
1630 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1631 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1632 let context_server_registry =
1633 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1634 let model = Arc::new(FakeLanguageModel::default());
1635 let thread = cx.new(|cx| {
1636 crate::Thread::new(
1637 project.clone(),
1638 cx.new(|_cx| ProjectContext::default()),
1639 context_server_registry,
1640 Templates::new(),
1641 Some(model),
1642 cx,
1643 )
1644 });
1645
1646 // Edit A spans lines 3-4, edit B spans lines 2-3. They overlap on
1647 // "line 3" and are given in descending file order so the ascending
1648 // sort must reorder them before the pairwise overlap check can
1649 // detect them correctly.
1650 let result = cx
1651 .update(|cx| {
1652 let input = StreamingEditFileToolInput {
1653 display_description: "Overlapping edits".into(),
1654 path: "root/file.txt".into(),
1655 mode: StreamingEditFileMode::Edit,
1656 content: None,
1657 edits: Some(vec![
1658 EditOperation {
1659 old_text: "line 3\nline 4".into(),
1660 new_text: "SECOND".into(),
1661 },
1662 EditOperation {
1663 old_text: "line 2\nline 3".into(),
1664 new_text: "FIRST".into(),
1665 },
1666 ]),
1667 };
1668 Arc::new(StreamingEditFileTool::new(
1669 project,
1670 thread.downgrade(),
1671 language_registry,
1672 ))
1673 .run(
1674 ToolInput::resolved(input),
1675 ToolCallEventStream::test().0,
1676 cx,
1677 )
1678 })
1679 .await;
1680
1681 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
1682 panic!("expected error");
1683 };
1684 assert!(
1685 error.contains("Conflicting edit ranges detected"),
1686 "Expected 'Conflicting edit ranges detected' but got: {error}"
1687 );
1688 }
1689
1690 #[gpui::test]1612 #[gpui::test]
1691 async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {1613 async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {
1692 init_test(cx);1614 init_test(cx);
@@ -1809,7 +1731,7 @@ mod tests { · 116 unmodified lines
1809 sender.send_partial(json!({1731 sender.send_partial(json!({
1810 "display_description": "Overwrite file",1732 "display_description": "Overwrite file",
1811 "path": "root/file.txt",1733 "path": "root/file.txt",
1812 "mode": "overwrite"1734 "mode": "write"
1813 }));1735 }));
1814 cx.run_until_parked();1736 cx.run_until_parked();
1815 1737
@@ -1817,7 +1739,7 @@ mod tests { · 1 unmodified line
1817 sender.send_final(json!({1739 sender.send_final(json!({
1818 "display_description": "Overwrite file",1740 "display_description": "Overwrite file",
1819 "path": "root/file.txt",1741 "path": "root/file.txt",
1820 "mode": "overwrite",1742 "mode": "write",
1821 "content": "new content"1743 "content": "new content"
1822 }));1744 }));
1823 1745
@@ -2026,14 +1948,14 @@ mod tests { · 202 unmodified lines
2026 sender.send_partial(json!({1948 sender.send_partial(json!({
2027 "display_description": "Create new file",1949 "display_description": "Create new file",
2028 "path": "root/dir/new_file.txt",1950 "path": "root/dir/new_file.txt",
2029 "mode": "create"1951 "mode": "write"
2030 }));1952 }));
2031 cx.run_until_parked();1953 cx.run_until_parked();
2032 1954
2033 sender.send_partial(json!({1955 sender.send_partial(json!({
2034 "display_description": "Create new file",1956 "display_description": "Create new file",
2035 "path": "root/dir/new_file.txt",1957 "path": "root/dir/new_file.txt",
2036 "mode": "create",1958 "mode": "write",
2037 "content": "Hello, "1959 "content": "Hello, "
2038 }));1960 }));
2039 cx.run_until_parked();1961 cx.run_until_parked();
@@ -2042,7 +1964,7 @@ mod tests { · 2 unmodified lines
2042 sender.send_final(json!({1964 sender.send_final(json!({
2043 "display_description": "Create new file",1965 "display_description": "Create new file",
2044 "path": "root/dir/new_file.txt",1966 "path": "root/dir/new_file.txt",
2045 "mode": "create",1967 "mode": "write",
2046 "content": "Hello, World!"1968 "content": "Hello, World!"
2047 }));1969 }));
2048 1970
@@ -2304,14 +2226,16 @@ mod tests { · 255 unmodified lines
2304 }));2226 }));
2305 cx.run_until_parked();2227 cx.run_until_parked();
2306 2228
2307 // Verify edit 1 applied2229 // Verify edit 1 fully applied. Edit 2's new_text is being
2230 // streamed: "CCC" is inserted but the old "ccc" isn't deleted
2231 // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done).
2308 let buffer_text = project.update(cx, |project, cx| {2232 let buffer_text = project.update(cx, |project, cx| {
2309 let pp = project2233 let pp = project
2310 .find_project_path(&PathBuf::from("root/file.txt"), cx)2234 .find_project_path(&PathBuf::from("root/file.txt"), cx)
2311 .unwrap();2235 .unwrap();
2312 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())2236 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2313 });2237 });
2314 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nccc\nddd\neee\n"));2238 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n"));
2315 2239
2316 // Edit 3 appears — edit 2 is now complete and should be applied2240 // Edit 3 appears — edit 2 is now complete and should be applied
2317 sender.send_partial(json!({2241 sender.send_partial(json!({
@@ -2326,14 +2250,15 @@ mod tests { · 8 unmodified lines
2326 }));2250 }));
2327 cx.run_until_parked();2251 cx.run_until_parked();
2328 2252
2329 // Verify edits 1 and 2 both applied2253 // Verify edits 1 and 2 fully applied. Edit 3's new_text is being
2254 // streamed: "EEE" is inserted but old "eee" isn't deleted yet.
2330 let buffer_text = project.update(cx, |project, cx| {2255 let buffer_text = project.update(cx, |project, cx| {
2331 let pp = project2256 let pp = project
2332 .find_project_path(&PathBuf::from("root/file.txt"), cx)2257 .find_project_path(&PathBuf::from("root/file.txt"), cx)
2333 .unwrap();2258 .unwrap();
2334 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())2259 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
2335 });2260 });
2336 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\neee\n"));2261 assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n"));
2337 2262
2338 // Send final2263 // Send final
2339 sender.send_final(json!({2264 sender.send_final(json!({
@@ -2466,82 +2391,6 @@ mod tests { · 126 unmodified lines
2466 );2391 );
2467 }2392 }
2468 2393
2469 #[gpui::test]
2470 async fn test_streaming_overlapping_edits_detected_naturally(cx: &mut TestAppContext) {
2471 init_test(cx);
2472
2473 let fs = project::FakeFs::new(cx.executor());
2474 fs.insert_tree(
2475 "/root",
2476 json!({
2477 "file.txt": "line 1\nline 2\nline 3\n"
2478 }),
2479 )
2480 .await;
2481 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2482 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
2483 let context_server_registry =
2484 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2485 let model = Arc::new(FakeLanguageModel::default());
2486 let thread = cx.new(|cx| {
2487 crate::Thread::new(
2488 project.clone(),
2489 cx.new(|_cx| ProjectContext::default()),
2490 context_server_registry,
2491 Templates::new(),
2492 Some(model),
2493 cx,
2494 )
2495 });
2496
2497 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
2498 let (event_stream, _receiver) = ToolCallEventStream::test();
2499
2500 let tool = Arc::new(StreamingEditFileTool::new(
2501 project.clone(),
2502 thread.downgrade(),
2503 language_registry,
2504 ));
2505
2506 let task = cx.update(|cx| tool.run(input, event_stream, cx));
2507
2508 // Setup
2509 sender.send_partial(json!({
2510 "display_description": "Overlapping edits",
2511 "path": "root/file.txt",
2512 "mode": "edit"
2513 }));
2514 cx.run_until_parked();
2515
2516 // Edit 1 targets "line 1\nline 2" and replaces it.
2517 // Edit 2 targets "line 2\nline 3" — but after edit 1 is applied,
2518 // "line 2" has been removed so this should fail to match.
2519 // Edit 3 exists to make edit 2 "complete" during streaming.
2520 sender.send_partial(json!({
2521 "display_description": "Overlapping edits",
2522 "path": "root/file.txt",
2523 "mode": "edit",
2524 "edits": [
2525 {"old_text": "line 1\nline 2", "new_text": "REPLACED"},
2526 {"old_text": "line 2\nline 3", "new_text": "ALSO REPLACED"},
2527 {"old_text": "line 3", "new_text": "DUMMY"}
2528 ]
2529 }));
2530 cx.run_until_parked();
2531
2532 // Edit 1 was applied, edit 2 should fail since "line 2" no longer exists
2533 drop(sender);
2534
2535 let result = task.await;
2536 let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else {
2537 panic!("expected error");
2538 };
2539 assert!(
2540 error.contains("Could not find matching text for edit at index 1"),
2541 "Expected overlapping edit to fail naturally, got: {error}"
2542 );
2543 }
2544
2545 #[gpui::test]2394 #[gpui::test]
2546 async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {2395 async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {
2547 init_test(cx);2396 init_test(cx);
@@ -2590,7 +2439,10 @@ mod tests { · 42 unmodified lines
2590 }));2439 }));
2591 cx.run_until_parked();2440 cx.run_until_parked();
2592 2441
2593 // Buffer should NOT be modified — the single edit is still in-progress2442 // The edit's old_text and new_text both arrived in one partial, so
2443 // the old_text is resolved and new_text is being streamed via
2444 // StreamingDiff. The buffer reflects the in-progress diff (new text
2445 // inserted, old text not yet fully removed until finalization).
2594 let buffer_text = project.update(cx, |project, cx| {2446 let buffer_text = project.update(cx, |project, cx| {
2595 let pp = project2447 let pp = project
2596 .find_project_path(&PathBuf::from("root/file.txt"), cx)2448 .find_project_path(&PathBuf::from("root/file.txt"), cx)
@@ -2599,8 +2451,8 @@ mod tests { · 2 unmodified lines
2599 });2451 });
2600 assert_eq!(2452 assert_eq!(
2601 buffer_text.as_deref(),2453 buffer_text.as_deref(),
2602 Some("hello world\n"),2454 Some("goodbye worldhello world\n"),
2603 "Single in-progress edit should not be applied during streaming"2455 "In-progress streaming diff: new text inserted, old text not yet removed"
2604 );2456 );
2605 2457
2606 // Send final — the edit is applied during finalization2458 // Send final — the edit is applied during finalization
@@ -2795,12 +2647,12 @@ mod tests { · 188 unmodified lines
2795 sender.send_partial(json!({2647 sender.send_partial(json!({
2796 "display_description": "Create",2648 "display_description": "Create",
2797 "path": "root/dir/new.txt",2649 "path": "root/dir/new.txt",
2798 "mode": "create"2650 "mode": "write"
2799 }));2651 }));
2800 sender.send_final(json!({2652 sender.send_final(json!({
2801 "display_description": "Create",2653 "display_description": "Create",
2802 "path": "root/dir/new.txt",2654 "path": "root/dir/new.txt",
2803 "mode": "create",2655 "mode": "write",
2804 "content": "streamed content"2656 "content": "streamed content"
2805 }));2657 }));
2806 2658
@@ -2813,7 +2665,7 @@ mod tests { · 6 unmodified lines
2813 2665
2814 #[gpui::test]2666 #[gpui::test]
2815 async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {2667 async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {
2816 let mode = StreamingEditFileMode::Create;2668 let mode = StreamingEditFileMode::Write;
2817 2669
2818 let result = test_resolve_path(&mode, "root/new.txt", cx);2670 let result = test_resolve_path(&mode, "root/new.txt", cx);
2819 assert_resolved_path_eq(result.await, rel_path("new.txt"));2671 assert_resolved_path_eq(result.await, rel_path("new.txt"));
@@ -2825,9 +2677,12 @@ mod tests { · 5 unmodified lines
2825 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));2677 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
2826 2678
2827 let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx);2679 let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx);
2680 assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt"));
2681
2682 let result = test_resolve_path(&mode, "root/dir/subdir", cx);
2828 assert_eq!(2683 assert_eq!(
2829 result.await.unwrap_err().to_string(),2684 result.await.unwrap_err().to_string(),
2830 "Can't create file: file already exists"2685 "Can't write to file: path is a directory"
2831 );2686 );
2832 2687
2833 let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);2688 let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);
@@ -3003,14 +2858,14 @@ mod tests { · 169 unmodified lines
3003 sender.send_partial(json!({2858 sender.send_partial(json!({
3004 "display_description": "Create main function",2859 "display_description": "Create main function",
3005 "path": "root/src/main.rs",2860 "path": "root/src/main.rs",
3006 "mode": "overwrite"2861 "mode": "write"
3007 }));2862 }));
3008 cx.run_until_parked();2863 cx.run_until_parked();
3009 2864
3010 sender.send_final(json!({2865 sender.send_final(json!({
3011 "display_description": "Create main function",2866 "display_description": "Create main function",
3012 "path": "root/src/main.rs",2867 "path": "root/src/main.rs",
3013 "mode": "overwrite",2868 "mode": "write",
3014 "content": UNFORMATTED_CONTENT2869 "content": UNFORMATTED_CONTENT
3015 }));2870 }));
3016 2871
@@ -3060,14 +2915,14 @@ mod tests { · 43 unmodified lines
3060 sender.send_partial(json!({2915 sender.send_partial(json!({
3061 "display_description": "Update main function",2916 "display_description": "Update main function",
3062 "path": "root/src/main.rs",2917 "path": "root/src/main.rs",
3063 "mode": "overwrite"2918 "mode": "write"
3064 }));2919 }));
3065 cx.run_until_parked();2920 cx.run_until_parked();
3066 2921
3067 sender.send_final(json!({2922 sender.send_final(json!({
3068 "display_description": "Update main function",2923 "display_description": "Update main function",
3069 "path": "root/src/main.rs",2924 "path": "root/src/main.rs",
3070 "mode": "overwrite",2925 "mode": "write",
3071 "content": UNFORMATTED_CONTENT2926 "content": UNFORMATTED_CONTENT
3072 }));2927 }));
3073 2928
@@ -3136,7 +2991,7 @@ mod tests { · 62 unmodified lines
3136 let input = StreamingEditFileToolInput {2991 let input = StreamingEditFileToolInput {
3137 display_description: "Create main function".into(),2992 display_description: "Create main function".into(),
3138 path: "root/src/main.rs".into(),2993 path: "root/src/main.rs".into(),
3139 mode: StreamingEditFileMode::Overwrite,2994 mode: StreamingEditFileMode::Write,
3140 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),2995 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
3141 edits: None,2996 edits: None,
3142 };2997 };
@@ -3183,7 +3038,7 @@ mod tests { · 40 unmodified lines
3183 let input = StreamingEditFileToolInput {3038 let input = StreamingEditFileToolInput {
3184 display_description: "Update main function".into(),3039 display_description: "Update main function".into(),
3185 path: "root/src/main.rs".into(),3040 path: "root/src/main.rs".into(),
3186 mode: StreamingEditFileMode::Overwrite,3041 mode: StreamingEditFileMode::Write,
3187 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),3042 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
3188 edits: None,3043 edits: None,
3189 };3044 };
@@ -3904,11 +3759,7 @@ mod tests { · 714 unmodified lines
3904 language_registry,3759 language_registry,
3905 ));3760 ));
3906 3761
3907 let modes = vec![3762 let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write];
3908 StreamingEditFileMode::Edit,
3909 StreamingEditFileMode::Create,
3910 StreamingEditFileMode::Overwrite,
3911 ];
3912 3763
3913 for _mode in modes {3764 for _mode in modes {
3914 // Test .zed path with different modes3765 // Test .zed path with different modes
@@ -4061,7 +3912,7 @@ mod tests { · 146 unmodified lines
4061 ToolInput::resolved(StreamingEditFileToolInput {3912 ToolInput::resolved(StreamingEditFileToolInput {
4062 display_description: "Edit file".into(),3913 display_description: "Edit file".into(),
4063 path: path!("/main.rs").into(),3914 path: path!("/main.rs").into(),
4064 mode: StreamingEditFileMode::Overwrite,3915 mode: StreamingEditFileMode::Write,
4065 content: Some("new content".into()),3916 content: Some("new content".into()),
4066 edits: None,3917 edits: None,
4067 }),3918 }),
@@ -4090,7 +3941,7 @@ mod tests { · 22 unmodified lines
4090 ToolInput::resolved(StreamingEditFileToolInput {3941 ToolInput::resolved(StreamingEditFileToolInput {
4091 display_description: "Edit file".into(),3942 display_description: "Edit file".into(),
4092 path: path!("/main.rs").into(),3943 path: path!("/main.rs").into(),
4093 mode: StreamingEditFileMode::Overwrite,3944 mode: StreamingEditFileMode::Write,
4094 content: Some("dropped content".into()),3945 content: Some("dropped content".into()),
4095 edits: None,3946 edits: None,
4096 }),3947 }),
@@ -4171,7 +4022,7 @@ mod tests { · 74 unmodified lines
4171 path: "root/test.txt".into(),4022 path: "root/test.txt".into(),
4172 mode: StreamingEditFileMode::Edit,4023 mode: StreamingEditFileMode::Edit,
4173 content: None,4024 content: None,
4174 edits: Some(vec![EditOperation {4025 edits: Some(vec![Edit {
4175 old_text: "original content".into(),4026 old_text: "original content".into(),
4176 new_text: "modified content".into(),4027 new_text: "modified content".into(),
4177 }]),4028 }]),
@@ -4196,7 +4047,7 @@ mod tests { · 18 unmodified lines
4196 path: "root/test.txt".into(),4047 path: "root/test.txt".into(),
4197 mode: StreamingEditFileMode::Edit,4048 mode: StreamingEditFileMode::Edit,
4198 content: None,4049 content: None,
4199 edits: Some(vec![EditOperation {4050 edits: Some(vec![Edit {
4200 old_text: "modified content".into(),4051 old_text: "modified content".into(),
4201 new_text: "further modified content".into(),4052 new_text: "further modified content".into(),
4202 }]),4053 }]),
@@ -4305,7 +4156,7 @@ mod tests { · 102 unmodified lines
4305 path: "root/test.txt".into(),4156 path: "root/test.txt".into(),
4306 mode: StreamingEditFileMode::Edit,4157 mode: StreamingEditFileMode::Edit,
4307 content: None,4158 content: None,
4308 edits: Some(vec![EditOperation {4159 edits: Some(vec![Edit {
4309 old_text: "externally modified content".into(),4160 old_text: "externally modified content".into(),
4310 new_text: "new content".into(),4161 new_text: "new content".into(),
4311 }]),4162 }]),
@@ -4409,7 +4260,7 @@ mod tests { · 97 unmodified lines
4409 path: "root/test.txt".into(),4260 path: "root/test.txt".into(),
4410 mode: StreamingEditFileMode::Edit,4261 mode: StreamingEditFileMode::Edit,
4411 content: None,4262 content: None,
4412 edits: Some(vec![EditOperation {4263 edits: Some(vec![Edit {
4413 old_text: "original content".into(),4264 old_text: "original content".into(),
4414 new_text: "new content".into(),4265 new_text: "new content".into(),
4415 }]),4266 }]),
@@ -4441,15 +4292,14 @@ mod tests { · 25 unmodified lines
4441 }4292 }
4442 4293
4443 #[gpui::test]4294 #[gpui::test]
4444 async fn test_streaming_overlapping_edits_detected_early(cx: &mut TestAppContext) {4295 async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) {
4445 init_test(cx);4296 init_test(cx);
4446 4297
4447 let fs = project::FakeFs::new(cx.executor());4298 let fs = project::FakeFs::new(cx.executor());
4448 // The file content is crafted so that edit 1's replacement still4299 // Edit 1's replacement introduces text that contains edit 2's
4449 // contains the old_text of edit 2 as a contiguous substring.4300 // old_text as a substring. Because edits resolve sequentially
4450 // Without early overlap detection, edit 2 would silently match4301 // against the current buffer, edit 2 finds a unique match in
4451 // inside the already-modified region and corrupt the file instead4302 // the modified buffer and succeeds.
4452 // of producing a clear "Conflicting edit ranges" error.
4453 fs.insert_tree(4303 fs.insert_tree(
4454 "/root",4304 "/root",
4455 json!({4305 json!({
@@ -4492,17 +4342,10 @@ mod tests { · 36 unmodified lines
4492 }));4342 }));
4493 cx.run_until_parked();4343 cx.run_until_parked();
4494 4344
4495 // Edit 1 targets "bbb\nccc" (lines 2-3) and replaces it with4345 // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
4496 // text that preserves "ccc\nddd" as a contiguous substring in the4346 // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
4497 // buffer — so edit 2's old_text will still match after edit 1 is4347 // Edit 2's old_text "ccc\nddd" matches the first occurrence
4498 // applied.4348 // in the modified buffer and replaces it with "ZZZ".
4499 //
4500 // Edit 2 targets "ccc\nddd" (lines 3-4), overlapping with edit 1 on
4501 // line 3 ("ccc"). After edit 1 runs, the buffer becomes:
4502 // "aaa\nXXX\nccc\nddd\nddd\neee\n"
4503 // and "ccc\nddd" is still present, so edit 2 would silently
4504 // succeed without early overlap detection.
4505 //
4506 // Edit 3 exists only to mark edit 2 as "complete" during streaming.4349 // Edit 3 exists only to mark edit 2 as "complete" during streaming.
4507 sender.send_partial(json!({4350 sender.send_partial(json!({
4508 "display_description": "Overlapping edits",4351 "display_description": "Overlapping edits",
@@ -4529,23 +4372,10 @@ mod tests { · 20 unmodified lines
4529 }));4372 }));
4530 4373
4531 let result = task.await;4374 let result = task.await;
4532 // We expect a "Conflicting edit ranges" error. Currently the overlap4375 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
4533 // goes undetected during streaming and the file gets silently4376 panic!("expected success");
4534 // corrupted, so this assertion will fail until we add early overlap4377 };
4535 // detection.4378 assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
4536 match result {
4537 Err(StreamingEditFileToolOutput::Error { error })
4538 if error.contains("Conflicting edit ranges") => {}
4539 Err(StreamingEditFileToolOutput::Error { error }) => {
4540 panic!("Expected 'Conflicting edit ranges' error, got different error: {error}");
4541 }
4542 Ok(output) => {
4543 panic!("Expected 'Conflicting edit ranges' error, but got success: {output}");
4544 }
4545 Err(other) => {
4546 panic!("Expected 'Conflicting edit ranges' error, got unexpected output: {other}");
4547 }
4548 }
4549 }4379 }
4550 4380
4551 #[gpui::test]4381 #[gpui::test]
@@ -4585,7 +4415,7 @@ mod tests { · 33 unmodified lines
4585 sender.send_partial(json!({4415 sender.send_partial(json!({
4586 "display_description": "Create new file",4416 "display_description": "Create new file",
4587 "path": "root/dir/new_file.txt",4417 "path": "root/dir/new_file.txt",
4588 "mode": "create"4418 "mode": "write"
4589 }));4419 }));
4590 cx.run_until_parked();4420 cx.run_until_parked();
4591 4421
@@ -4593,7 +4423,7 @@ mod tests { · 1 unmodified line
4593 sender.send_partial(json!({4423 sender.send_partial(json!({
4594 "display_description": "Create new file",4424 "display_description": "Create new file",
4595 "path": "root/dir/new_file.txt",4425 "path": "root/dir/new_file.txt",
4596 "mode": "create",4426 "mode": "write",
4597 "content": "line 1\n"4427 "content": "line 1\n"
4598 }));4428 }));
4599 cx.run_until_parked();4429 cx.run_until_parked();
@@ -4611,7 +4441,7 @@ mod tests { · 11 unmodified lines
4611 sender.send_partial(json!({4441 sender.send_partial(json!({
4612 "display_description": "Create new file",4442 "display_description": "Create new file",
4613 "path": "root/dir/new_file.txt",4443 "path": "root/dir/new_file.txt",
4614 "mode": "create",4444 "mode": "write",
4615 "content": "line 1\nline 2\n"4445 "content": "line 1\nline 2\n"
4616 }));4446 }));
4617 cx.run_until_parked();4447 cx.run_until_parked();
@@ -4621,7 +4451,7 @@ mod tests { · 3 unmodified lines
4621 sender.send_partial(json!({4451 sender.send_partial(json!({
4622 "display_description": "Create new file",4452 "display_description": "Create new file",
4623 "path": "root/dir/new_file.txt",4453 "path": "root/dir/new_file.txt",
4624 "mode": "create",4454 "mode": "write",
4625 "content": "line 1\nline 2\nline 3\n"4455 "content": "line 1\nline 2\nline 3\n"
4626 }));4456 }));
4627 cx.run_until_parked();4457 cx.run_until_parked();
@@ -4634,7 +4464,7 @@ mod tests { · 6 unmodified lines
4634 sender.send_final(json!({4464 sender.send_final(json!({
4635 "display_description": "Create new file",4465 "display_description": "Create new file",
4636 "path": "root/dir/new_file.txt",4466 "path": "root/dir/new_file.txt",
4637 "mode": "create",4467 "mode": "write",
4638 "content": "line 1\nline 2\nline 3\n"4468 "content": "line 1\nline 2\nline 3\n"
4639 }));4469 }));
4640 4470
@@ -4688,7 +4518,7 @@ mod tests { · 47 unmodified lines
4688 sender.send_partial(json!({4518 sender.send_partial(json!({
4689 "display_description": "Overwrite file",4519 "display_description": "Overwrite file",
4690 "path": "root/file.txt",4520 "path": "root/file.txt",
4691 "mode": "overwrite"4521 "mode": "write"
4692 }));4522 }));
4693 cx.run_until_parked();4523 cx.run_until_parked();
4694 4524
@@ -4706,7 +4536,7 @@ mod tests { · 11 unmodified lines
4706 sender.send_partial(json!({4536 sender.send_partial(json!({
4707 "display_description": "Overwrite file",4537 "display_description": "Overwrite file",
4708 "path": "root/file.txt",4538 "path": "root/file.txt",
4709 "mode": "overwrite",4539 "mode": "write",
4710 "content": "new line 1\n"4540 "content": "new line 1\n"
4711 }));4541 }));
4712 cx.run_until_parked();4542 cx.run_until_parked();
@@ -4720,7 +4550,7 @@ mod tests { · 7 unmodified lines
4720 sender.send_final(json!({4550 sender.send_final(json!({
4721 "display_description": "Overwrite file",4551 "display_description": "Overwrite file",
4722 "path": "root/file.txt",4552 "path": "root/file.txt",
4723 "mode": "overwrite",4553 "mode": "write",
4724 "content": "new line 1\nnew line 2\n"4554 "content": "new line 1\nnew line 2\n"
4725 }));4555 }));
4726 4556
@@ -4781,7 +4611,7 @@ mod tests { · 54 unmodified lines
4781 sender.send_partial(json!({4611 sender.send_partial(json!({
4782 "display_description": "Overwrite file",4612 "display_description": "Overwrite file",
4783 "path": "root/file.txt",4613 "path": "root/file.txt",
4784 "mode": "overwrite"4614 "mode": "write"
4785 }));4615 }));
4786 cx.run_until_parked();4616 cx.run_until_parked();
4787 4617
@@ -4799,7 +4629,7 @@ mod tests { · 11 unmodified lines
4799 sender.send_partial(json!({4629 sender.send_partial(json!({
4800 "display_description": "Overwrite file",4630 "display_description": "Overwrite file",
4801 "path": "root/file.txt",4631 "path": "root/file.txt",
4802 "mode": "overwrite",4632 "mode": "write",
4803 "content": "new line 1\n"4633 "content": "new line 1\n"
4804 }));4634 }));
4805 cx.run_until_parked();4635 cx.run_until_parked();
@@ -4809,7 +4639,7 @@ mod tests { · 3 unmodified lines
4809 sender.send_partial(json!({4639 sender.send_partial(json!({
4810 "display_description": "Overwrite file",4640 "display_description": "Overwrite file",
4811 "path": "root/file.txt",4641 "path": "root/file.txt",
4812 "mode": "overwrite",4642 "mode": "write",
4813 "content": "new line 1\nnew line 2\n"4643 "content": "new line 1\nnew line 2\n"
4814 }));4644 }));
4815 cx.run_until_parked();4645 cx.run_until_parked();
@@ -4822,7 +4652,7 @@ mod tests { · 6 unmodified lines
4822 sender.send_final(json!({4652 sender.send_final(json!({
4823 "display_description": "Overwrite file",4653 "display_description": "Overwrite file",
4824 "path": "root/file.txt",4654 "path": "root/file.txt",
4825 "mode": "overwrite",4655 "mode": "write",
4826 "content": "new line 1\nnew line 2\nnew line 3\n"4656 "content": "new line 1\nnew line 2\nnew line 3\n"
4827 }));4657 }));
4828 4658
@@ -4837,6 +4667,89 @@ mod tests { · 8 unmodified lines
4837 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");4667 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
4838 }4668 }
4839 4669
4670 #[gpui::test]
4671 async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) {
4672 init_test(cx);
4673
4674 let fs = project::FakeFs::new(cx.executor());
4675 fs.insert_tree(
4676 "/root",
4677 json!({
4678 "file.txt": "hello\nworld\nfoo\n"
4679 }),
4680 )
4681 .await;
4682 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4683 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
4684 let context_server_registry =
4685 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
4686 let model = Arc::new(FakeLanguageModel::default());
4687 let thread = cx.new(|cx| {
4688 crate::Thread::new(
4689 project.clone(),
4690 cx.new(|_cx| ProjectContext::default()),
4691 context_server_registry,
4692 Templates::new(),
4693 Some(model),
4694 cx,
4695 )
4696 });
4697
4698 let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
4699 let (event_stream, _receiver) = ToolCallEventStream::test();
4700
4701 let tool = Arc::new(StreamingEditFileTool::new(
4702 project.clone(),
4703 thread.downgrade(),
4704 language_registry,
4705 ));
4706
4707 let task = cx.update(|cx| tool.run(input, event_stream, cx));
4708
4709 sender.send_partial(json!({
4710 "display_description": "Edit",
4711 "path": "root/file.txt",
4712 "mode": "edit"
4713 }));
4714 cx.run_until_parked();
4715
4716 // Simulate JSON fixer producing a literal backslash when the LLM
4717 // stream cuts in the middle of a \n escape sequence.
4718 // The old_text "hello\nworld" would be streamed as:
4719 // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\)
4720 // partial 2: old_text = "hello\nworld" (fixer corrected the escape)
4721 sender.send_partial(json!({
4722 "display_description": "Edit",
4723 "path": "root/file.txt",
4724 "mode": "edit",
4725 "edits": [{"old_text": "hello\\"}]
4726 }));
4727 cx.run_until_parked();
4728
4729 // Now the fixer corrects it to the real newline.
4730 sender.send_partial(json!({
4731 "display_description": "Edit",
4732 "path": "root/file.txt",
4733 "mode": "edit",
4734 "edits": [{"old_text": "hello\nworld"}]
4735 }));
4736 cx.run_until_parked();
4737
4738 // Send final.
4739 sender.send_final(json!({
4740 "display_description": "Edit",
4741 "path": "root/file.txt",
4742 "mode": "edit",
4743 "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}]
4744 }));
4745
4746 let result = task.await;
4747 let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
4748 panic!("expected success");
4749 };
4750 assert_eq!(new_text, "HELLO\nWORLD\nfoo\n");
4751 }
4752
4840 fn init_test(cx: &mut TestAppContext) {4753 fn init_test(cx: &mut TestAppContext) {
4841 cx.update(|cx| {4754 cx.update(|cx| {
4842 let settings_store = SettingsStore::test(cx);4755 let settings_store = SettingsStore::test(cx);
crates/agent/src/tools/tool_edit_parser.rs+941−0
@@ -0,0 +1,941 @@
1use smallvec::SmallVec;
2
3use crate::{Edit, PartialEdit};
4
5/// Events emitted by `ToolEditParser` as tool call input streams in.
6#[derive(Debug, PartialEq, Eq)]
7pub enum ToolEditEvent {
8 /// A chunk of `old_text` for an edit operation.
9 OldTextChunk {
10 edit_index: usize,
11 chunk: String,
12 done: bool,
13 },
14 /// A chunk of `new_text` for an edit operation.
15 NewTextChunk {
16 edit_index: usize,
17 chunk: String,
18 done: bool,
19 },
20 /// A chunk of content for write/overwrite mode.
21 ContentChunk { chunk: String },
22}
23
24/// Tracks the streaming state of a single edit to detect deltas.
25#[derive(Default, Debug)]
26struct EditStreamState {
27 old_text_emitted_len: usize,
28 old_text_done: bool,
29 new_text_emitted_len: usize,
30 new_text_done: bool,
31}
32
33/// Converts incrementally-growing tool call JSON into a stream of chunk events.
34///
35/// The tool call streaming infrastructure delivers partial JSON objects where
36/// string fields grow over time. This parser compares consecutive partials,
37/// computes the deltas, and emits `ToolEditEvent`s that downstream pipeline
38/// stages (`StreamingFuzzyMatcher` for old_text, `StreamingDiff` for new_text)
39/// can consume incrementally.
40///
41/// Because partial JSON comes through a fixer (`partial-json-fixer`) that
42/// closes incomplete escape sequences, a string can temporarily contain wrong
43/// trailing characters (e.g. a literal `\` instead of `\n`). We handle this
44/// by holding back trailing backslash characters in non-finalized chunks: if
45/// a partial string ends with `\` (0x5C), that byte is not emitted until the
46/// next partial confirms or corrects it. This avoids feeding corrupted bytes
47/// to downstream consumers.
48#[derive(Default, Debug)]
49pub struct ToolEditParser {
50 edit_states: Vec<EditStreamState>,
51 content_emitted_len: usize,
52}
53
54impl ToolEditParser {
55 /// Push a new set of partial edits (from edit mode) and return any events.
56 ///
57 /// Each call should pass the *entire current* edits array as seen in the
58 /// latest partial input. The parser will diff it against its internal state
59 /// to produce only the new events.
60 pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[ToolEditEvent; 4]> {
61 let mut events = SmallVec::new();
62
63 for (index, partial) in edits.iter().enumerate() {
64 if index >= self.edit_states.len() {
65 // A new edit appeared — finalize the previous one if there was one.
66 if let Some(previous) = self.finalize_previous_edit(index) {
67 events.extend(previous);
68 }
69 self.edit_states.push(EditStreamState::default());
70 }
71
72 let state = &mut self.edit_states[index];
73
74 // Process old_text changes.
75 if let Some(old_text) = &partial.old_text
76 && !state.old_text_done
77 {
78 if partial.new_text.is_some() {
79 // new_text appeared, so old_text is done — emit everything.
80 let start = state.old_text_emitted_len.min(old_text.len());
81 let chunk = old_text[start..].to_string();
82 state.old_text_done = true;
83 state.old_text_emitted_len = old_text.len();
84 events.push(ToolEditEvent::OldTextChunk {
85 edit_index: index,
86 chunk,
87 done: true,
88 });
89 } else {
90 let safe_end = safe_emit_end(old_text);
91 if safe_end > state.old_text_emitted_len {
92 let chunk = old_text[state.old_text_emitted_len..safe_end].to_string();
93 state.old_text_emitted_len = safe_end;
94 events.push(ToolEditEvent::OldTextChunk {
95 edit_index: index,
96 chunk,
97 done: false,
98 });
99 }
100 }
101 }
102
103 // Process new_text changes.
104 if let Some(new_text) = &partial.new_text
105 && !state.new_text_done
106 {
107 let safe_end = safe_emit_end(new_text);
108 if safe_end > state.new_text_emitted_len {
109 let chunk = new_text[state.new_text_emitted_len..safe_end].to_string();
110 state.new_text_emitted_len = safe_end;
111 events.push(ToolEditEvent::NewTextChunk {
112 edit_index: index,
113 chunk,
114 done: false,
115 });
116 }
117 }
118 }
119
120 events
121 }
122
123 /// Push new content and return any events.
124 ///
125 /// Each call should pass the *entire current* content string. The parser
126 /// will diff it against its internal state to emit only the new chunk.
127 pub fn push_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
128 let mut events = SmallVec::new();
129
130 let safe_end = safe_emit_end(content);
131 if safe_end > self.content_emitted_len {
132 let chunk = content[self.content_emitted_len..safe_end].to_string();
133 self.content_emitted_len = safe_end;
134 events.push(ToolEditEvent::ContentChunk { chunk });
135 }
136
137 events
138 }
139
140 /// Finalize all edits with the complete input. This emits `done: true`
141 /// events for any in-progress old_text or new_text that hasn't been
142 /// finalized yet.
143 ///
144 /// `final_edits` should be the fully deserialized final edits array. The
145 /// parser compares against its tracked state and emits any remaining deltas
146 /// with `done: true`.
147 pub fn finalize_edits(&mut self, edits: &[Edit]) -> SmallVec<[ToolEditEvent; 4]> {
148 let mut events = SmallVec::new();
149
150 for (index, edit) in edits.iter().enumerate() {
151 if index >= self.edit_states.len() {
152 // This edit was never seen in partials — emit it fully.
153 if let Some(previous) = self.finalize_previous_edit(index) {
154 events.extend(previous);
155 }
156 self.edit_states.push(EditStreamState::default());
157 }
158
159 let state = &mut self.edit_states[index];
160
161 if !state.old_text_done {
162 let start = state.old_text_emitted_len.min(edit.old_text.len());
163 let chunk = edit.old_text[start..].to_string();
164 state.old_text_done = true;
165 state.old_text_emitted_len = edit.old_text.len();
166 events.push(ToolEditEvent::OldTextChunk {
167 edit_index: index,
168 chunk,
169 done: true,
170 });
171 }
172
173 if !state.new_text_done {
174 let start = state.new_text_emitted_len.min(edit.new_text.len());
175 let chunk = edit.new_text[start..].to_string();
176 state.new_text_done = true;
177 state.new_text_emitted_len = edit.new_text.len();
178 events.push(ToolEditEvent::NewTextChunk {
179 edit_index: index,
180 chunk,
181 done: true,
182 });
183 }
184 }
185
186 events
187 }
188
189 /// Finalize content with the complete input.
190 pub fn finalize_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
191 let mut events = SmallVec::new();
192
193 let start = self.content_emitted_len.min(content.len());
194 if content.len() > start {
195 let chunk = content[start..].to_string();
196 self.content_emitted_len = content.len();
197 events.push(ToolEditEvent::ContentChunk { chunk });
198 }
199
200 events
201 }
202
203 /// When a new edit appears at `index`, finalize the edit at `index - 1`
204 /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized.
205 fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[ToolEditEvent; 2]>> {
206 if new_index == 0 || self.edit_states.is_empty() {
207 return None;
208 }
209
210 let previous_index = new_index - 1;
211 if previous_index >= self.edit_states.len() {
212 return None;
213 }
214
215 let state = &mut self.edit_states[previous_index];
216 let mut events = SmallVec::new();
217
218 // If old_text was never finalized, finalize it now with an empty done chunk.
219 if !state.old_text_done {
220 state.old_text_done = true;
221 events.push(ToolEditEvent::OldTextChunk {
222 edit_index: previous_index,
223 chunk: String::new(),
224 done: true,
225 });
226 }
227
228 // Emit a done event for new_text if not already finalized.
229 if !state.new_text_done {
230 state.new_text_done = true;
231 events.push(ToolEditEvent::NewTextChunk {
232 edit_index: previous_index,
233 chunk: String::new(),
234 done: true,
235 });
236 }
237
238 Some(events)
239 }
240}
241
242/// Returns the byte position up to which it is safe to emit from a partial
243/// string. If the string ends with a backslash (`\`, 0x5C), that byte is
244/// held back because it may be an artifact of the partial JSON fixer closing
245/// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
246/// The next partial will reveal the correct character.
247fn safe_emit_end(text: &str) -> usize {
248 if text.as_bytes().last() == Some(&b'\\') {
249 text.len() - 1
250 } else {
251 text.len()
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_single_edit_streamed_incrementally() {
261 let mut parser = ToolEditParser::default();
262
263 // old_text arrives in chunks: "hell" → "hello w" → "hello world"
264 let events = parser.push_edits(&[PartialEdit {
265 old_text: Some("hell".into()),
266 new_text: None,
267 }]);
268 assert_eq!(
269 events.as_slice(),
270 &[ToolEditEvent::OldTextChunk {
271 edit_index: 0,
272 chunk: "hell".into(),
273 done: false,
274 }]
275 );
276
277 let events = parser.push_edits(&[PartialEdit {
278 old_text: Some("hello w".into()),
279 new_text: None,
280 }]);
281 assert_eq!(
282 events.as_slice(),
283 &[ToolEditEvent::OldTextChunk {
284 edit_index: 0,
285 chunk: "o w".into(),
286 done: false,
287 }]
288 );
289
290 // new_text appears → old_text finalizes
291 let events = parser.push_edits(&[PartialEdit {
292 old_text: Some("hello world".into()),
293 new_text: Some("good".into()),
294 }]);
295 assert_eq!(
296 events.as_slice(),
297 &[
298 ToolEditEvent::OldTextChunk {
299 edit_index: 0,
300 chunk: "orld".into(),
301 done: true,
302 },
303 ToolEditEvent::NewTextChunk {
304 edit_index: 0,
305 chunk: "good".into(),
306 done: false,
307 },
308 ]
309 );
310
311 // new_text grows
312 let events = parser.push_edits(&[PartialEdit {
313 old_text: Some("hello world".into()),
314 new_text: Some("goodbye world".into()),
315 }]);
316 assert_eq!(
317 events.as_slice(),
318 &[ToolEditEvent::NewTextChunk {
319 edit_index: 0,
320 chunk: "bye world".into(),
321 done: false,
322 }]
323 );
324
325 // Finalize
326 let events = parser.finalize_edits(&[Edit {
327 old_text: "hello world".into(),
328 new_text: "goodbye world".into(),
329 }]);
330 assert_eq!(
331 events.as_slice(),
332 &[ToolEditEvent::NewTextChunk {
333 edit_index: 0,
334 chunk: "".into(),
335 done: true,
336 }]
337 );
338 }
339
340 #[test]
341 fn test_multiple_edits_sequential() {
342 let mut parser = ToolEditParser::default();
343
344 // First edit streams in
345 let events = parser.push_edits(&[PartialEdit {
346 old_text: Some("first old".into()),
347 new_text: None,
348 }]);
349 assert_eq!(
350 events.as_slice(),
351 &[ToolEditEvent::OldTextChunk {
352 edit_index: 0,
353 chunk: "first old".into(),
354 done: false,
355 }]
356 );
357
358 let events = parser.push_edits(&[PartialEdit {
359 old_text: Some("first old".into()),
360 new_text: Some("first new".into()),
361 }]);
362 assert_eq!(
363 events.as_slice(),
364 &[
365 ToolEditEvent::OldTextChunk {
366 edit_index: 0,
367 chunk: "".into(),
368 done: true,
369 },
370 ToolEditEvent::NewTextChunk {
371 edit_index: 0,
372 chunk: "first new".into(),
373 done: false,
374 },
375 ]
376 );
377
378 // Second edit appears → first edit's new_text is finalized
379 let events = parser.push_edits(&[
380 PartialEdit {
381 old_text: Some("first old".into()),
382 new_text: Some("first new".into()),
383 },
384 PartialEdit {
385 old_text: Some("second".into()),
386 new_text: None,
387 },
388 ]);
389 assert_eq!(
390 events.as_slice(),
391 &[
392 ToolEditEvent::NewTextChunk {
393 edit_index: 0,
394 chunk: "".into(),
395 done: true,
396 },
397 ToolEditEvent::OldTextChunk {
398 edit_index: 1,
399 chunk: "second".into(),
400 done: false,
401 },
402 ]
403 );
404
405 // Finalize everything
406 let events = parser.finalize_edits(&[
407 Edit {
408 old_text: "first old".into(),
409 new_text: "first new".into(),
410 },
411 Edit {
412 old_text: "second old".into(),
413 new_text: "second new".into(),
414 },
415 ]);
416 assert_eq!(
417 events.as_slice(),
418 &[
419 ToolEditEvent::OldTextChunk {
420 edit_index: 1,
421 chunk: " old".into(),
422 done: true,
423 },
424 ToolEditEvent::NewTextChunk {
425 edit_index: 1,
426 chunk: "second new".into(),
427 done: true,
428 },
429 ]
430 );
431 }
432
433 #[test]
434 fn test_content_streamed_incrementally() {
435 let mut parser = ToolEditParser::default();
436
437 let events = parser.push_content("hello");
438 assert_eq!(
439 events.as_slice(),
440 &[ToolEditEvent::ContentChunk {
441 chunk: "hello".into(),
442 }]
443 );
444
445 let events = parser.push_content("hello world");
446 assert_eq!(
447 events.as_slice(),
448 &[ToolEditEvent::ContentChunk {
449 chunk: " world".into(),
450 }]
451 );
452
453 // No change
454 let events = parser.push_content("hello world");
455 assert!(events.is_empty());
456
457 let events = parser.push_content("hello world!");
458 assert_eq!(
459 events.as_slice(),
460 &[ToolEditEvent::ContentChunk { chunk: "!".into() }]
461 );
462
463 // Finalize with no additional content
464 let events = parser.finalize_content("hello world!");
465 assert!(events.is_empty());
466 }
467
468 #[test]
469 fn test_finalize_content_with_remaining() {
470 let mut parser = ToolEditParser::default();
471
472 parser.push_content("partial");
473 let events = parser.finalize_content("partial content here");
474 assert_eq!(
475 events.as_slice(),
476 &[ToolEditEvent::ContentChunk {
477 chunk: " content here".into(),
478 }]
479 );
480 }
481
482 #[test]
483 fn test_content_trailing_backslash_held_back() {
484 let mut parser = ToolEditParser::default();
485
486 // Partial JSON fixer turns incomplete \n into \\ (literal backslash).
487 // The trailing backslash is held back.
488 let events = parser.push_content("hello,\\");
489 assert_eq!(
490 events.as_slice(),
491 &[ToolEditEvent::ContentChunk {
492 chunk: "hello,".into(),
493 }]
494 );
495
496 // Next partial corrects the escape to an actual newline.
497 // The held-back byte was wrong; the correct newline is emitted.
498 let events = parser.push_content("hello,\n");
499 assert_eq!(
500 events.as_slice(),
501 &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
502 );
503
504 // Normal growth.
505 let events = parser.push_content("hello,\nworld");
506 assert_eq!(
507 events.as_slice(),
508 &[ToolEditEvent::ContentChunk {
509 chunk: "world".into(),
510 }]
511 );
512 }
513
514 #[test]
515 fn test_content_finalize_with_trailing_backslash() {
516 let mut parser = ToolEditParser::default();
517
518 // Stream a partial with a fixer-corrupted trailing backslash.
519 // The backslash is held back.
520 parser.push_content("abc\\");
521
522 // Finalize reveals the correct character.
523 let events = parser.finalize_content("abc\n");
524 assert_eq!(
525 events.as_slice(),
526 &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
527 );
528 }
529
530 #[test]
531 fn test_no_partials_direct_finalize() {
532 let mut parser = ToolEditParser::default();
533
534 let events = parser.finalize_edits(&[Edit {
535 old_text: "old".into(),
536 new_text: "new".into(),
537 }]);
538 assert_eq!(
539 events.as_slice(),
540 &[
541 ToolEditEvent::OldTextChunk {
542 edit_index: 0,
543 chunk: "old".into(),
544 done: true,
545 },
546 ToolEditEvent::NewTextChunk {
547 edit_index: 0,
548 chunk: "new".into(),
549 done: true,
550 },
551 ]
552 );
553 }
554
555 #[test]
556 fn test_no_partials_direct_finalize_multiple() {
557 let mut parser = ToolEditParser::default();
558
559 let events = parser.finalize_edits(&[
560 Edit {
561 old_text: "first old".into(),
562 new_text: "first new".into(),
563 },
564 Edit {
565 old_text: "second old".into(),
566 new_text: "second new".into(),
567 },
568 ]);
569 assert_eq!(
570 events.as_slice(),
571 &[
572 ToolEditEvent::OldTextChunk {
573 edit_index: 0,
574 chunk: "first old".into(),
575 done: true,
576 },
577 ToolEditEvent::NewTextChunk {
578 edit_index: 0,
579 chunk: "first new".into(),
580 done: true,
581 },
582 ToolEditEvent::OldTextChunk {
583 edit_index: 1,
584 chunk: "second old".into(),
585 done: true,
586 },
587 ToolEditEvent::NewTextChunk {
588 edit_index: 1,
589 chunk: "second new".into(),
590 done: true,
591 },
592 ]
593 );
594 }
595
596 #[test]
597 fn test_old_text_no_growth() {
598 let mut parser = ToolEditParser::default();
599
600 let events = parser.push_edits(&[PartialEdit {
601 old_text: Some("same".into()),
602 new_text: None,
603 }]);
604 assert_eq!(
605 events.as_slice(),
606 &[ToolEditEvent::OldTextChunk {
607 edit_index: 0,
608 chunk: "same".into(),
609 done: false,
610 }]
611 );
612
613 // Same old_text, no new_text → no events
614 let events = parser.push_edits(&[PartialEdit {
615 old_text: Some("same".into()),
616 new_text: None,
617 }]);
618 assert!(events.is_empty());
619 }
620
621 #[test]
622 fn test_old_text_none_then_appears() {
623 let mut parser = ToolEditParser::default();
624
625 // Edit exists but old_text is None (field hasn't arrived yet)
626 let events = parser.push_edits(&[PartialEdit {
627 old_text: None,
628 new_text: None,
629 }]);
630 assert!(events.is_empty());
631
632 // old_text appears
633 let events = parser.push_edits(&[PartialEdit {
634 old_text: Some("text".into()),
635 new_text: None,
636 }]);
637 assert_eq!(
638 events.as_slice(),
639 &[ToolEditEvent::OldTextChunk {
640 edit_index: 0,
641 chunk: "text".into(),
642 done: false,
643 }]
644 );
645 }
646
647 #[test]
648 fn test_empty_old_text_with_new_text() {
649 let mut parser = ToolEditParser::default();
650
651 // old_text is empty, new_text appears immediately
652 let events = parser.push_edits(&[PartialEdit {
653 old_text: Some("".into()),
654 new_text: Some("inserted".into()),
655 }]);
656 assert_eq!(
657 events.as_slice(),
658 &[
659 ToolEditEvent::OldTextChunk {
660 edit_index: 0,
661 chunk: "".into(),
662 done: true,
663 },
664 ToolEditEvent::NewTextChunk {
665 edit_index: 0,
666 chunk: "inserted".into(),
667 done: false,
668 },
669 ]
670 );
671 }
672
673 #[test]
674 fn test_three_edits_streamed() {
675 let mut parser = ToolEditParser::default();
676
677 // Stream first edit
678 parser.push_edits(&[PartialEdit {
679 old_text: Some("a".into()),
680 new_text: Some("A".into()),
681 }]);
682
683 // Second edit appears
684 parser.push_edits(&[
685 PartialEdit {
686 old_text: Some("a".into()),
687 new_text: Some("A".into()),
688 },
689 PartialEdit {
690 old_text: Some("b".into()),
691 new_text: Some("B".into()),
692 },
693 ]);
694
695 // Third edit appears
696 let events = parser.push_edits(&[
697 PartialEdit {
698 old_text: Some("a".into()),
699 new_text: Some("A".into()),
700 },
701 PartialEdit {
702 old_text: Some("b".into()),
703 new_text: Some("B".into()),
704 },
705 PartialEdit {
706 old_text: Some("c".into()),
707 new_text: None,
708 },
709 ]);
710
711 // Should finalize edit 1 (index=1) and start edit 2 (index=2)
712 assert_eq!(
713 events.as_slice(),
714 &[
715 ToolEditEvent::NewTextChunk {
716 edit_index: 1,
717 chunk: "".into(),
718 done: true,
719 },
720 ToolEditEvent::OldTextChunk {
721 edit_index: 2,
722 chunk: "c".into(),
723 done: false,
724 },
725 ]
726 );
727
728 // Finalize
729 let events = parser.finalize_edits(&[
730 Edit {
731 old_text: "a".into(),
732 new_text: "A".into(),
733 },
734 Edit {
735 old_text: "b".into(),
736 new_text: "B".into(),
737 },
738 Edit {
739 old_text: "c".into(),
740 new_text: "C".into(),
741 },
742 ]);
743 assert_eq!(
744 events.as_slice(),
745 &[
746 ToolEditEvent::OldTextChunk {
747 edit_index: 2,
748 chunk: "".into(),
749 done: true,
750 },
751 ToolEditEvent::NewTextChunk {
752 edit_index: 2,
753 chunk: "C".into(),
754 done: true,
755 },
756 ]
757 );
758 }
759
760 #[test]
761 fn test_finalize_with_unseen_old_text() {
762 let mut parser = ToolEditParser::default();
763
764 // Only saw partial old_text, never saw new_text in partials
765 parser.push_edits(&[PartialEdit {
766 old_text: Some("partial".into()),
767 new_text: None,
768 }]);
769
770 let events = parser.finalize_edits(&[Edit {
771 old_text: "partial old text".into(),
772 new_text: "replacement".into(),
773 }]);
774 assert_eq!(
775 events.as_slice(),
776 &[
777 ToolEditEvent::OldTextChunk {
778 edit_index: 0,
779 chunk: " old text".into(),
780 done: true,
781 },
782 ToolEditEvent::NewTextChunk {
783 edit_index: 0,
784 chunk: "replacement".into(),
785 done: true,
786 },
787 ]
788 );
789 }
790
791 #[test]
792 fn test_finalize_with_partially_seen_new_text() {
793 let mut parser = ToolEditParser::default();
794
795 parser.push_edits(&[PartialEdit {
796 old_text: Some("old".into()),
797 new_text: Some("partial".into()),
798 }]);
799
800 let events = parser.finalize_edits(&[Edit {
801 old_text: "old".into(),
802 new_text: "partial new text".into(),
803 }]);
804 assert_eq!(
805 events.as_slice(),
806 &[ToolEditEvent::NewTextChunk {
807 edit_index: 0,
808 chunk: " new text".into(),
809 done: true,
810 }]
811 );
812 }
813
814 #[test]
815 fn test_repeated_pushes_with_no_change() {
816 let mut parser = ToolEditParser::default();
817
818 let events = parser.push_edits(&[PartialEdit {
819 old_text: Some("stable".into()),
820 new_text: Some("also stable".into()),
821 }]);
822 assert_eq!(events.len(), 2); // old done + new chunk
823
824 // Push the exact same data again
825 let events = parser.push_edits(&[PartialEdit {
826 old_text: Some("stable".into()),
827 new_text: Some("also stable".into()),
828 }]);
829 assert!(events.is_empty());
830
831 // And again
832 let events = parser.push_edits(&[PartialEdit {
833 old_text: Some("stable".into()),
834 new_text: Some("also stable".into()),
835 }]);
836 assert!(events.is_empty());
837 }
838
839 #[test]
840 fn test_old_text_trailing_backslash_held_back() {
841 let mut parser = ToolEditParser::default();
842
843 // Partial-json-fixer produces a literal backslash when the JSON stream
844 // cuts in the middle of an escape sequence like \n. The parser holds
845 // back the trailing backslash instead of emitting it.
846 let events = parser.push_edits(&[PartialEdit {
847 old_text: Some("hello,\\".into()), // fixer closed incomplete \n as \\
848 new_text: None,
849 }]);
850 // The trailing `\` is held back — only "hello," is emitted.
851 assert_eq!(
852 events.as_slice(),
853 &[ToolEditEvent::OldTextChunk {
854 edit_index: 0,
855 chunk: "hello,".into(),
856 done: false,
857 }]
858 );
859
860 // Next partial: the fixer corrects the escape to \n.
861 // The held-back byte was wrong, but we never emitted it. Now the
862 // correct newline at that position is emitted normally.
863 let events = parser.push_edits(&[PartialEdit {
864 old_text: Some("hello,\n".into()),
865 new_text: None,
866 }]);
867 assert_eq!(
868 events.as_slice(),
869 &[ToolEditEvent::OldTextChunk {
870 edit_index: 0,
871 chunk: "\n".into(),
872 done: false,
873 }]
874 );
875
876 // Continue normally.
877 let events = parser.push_edits(&[PartialEdit {
878 old_text: Some("hello,\nworld".into()),
879 new_text: None,
880 }]);
881 assert_eq!(
882 events.as_slice(),
883 &[ToolEditEvent::OldTextChunk {
884 edit_index: 0,
885 chunk: "world".into(),
886 done: false,
887 }]
888 );
889 }
890
891 #[test]
892 fn test_multiline_old_and_new_text() {
893 let mut parser = ToolEditParser::default();
894
895 let events = parser.push_edits(&[PartialEdit {
896 old_text: Some("line1\nline2".into()),
897 new_text: None,
898 }]);
899 assert_eq!(
900 events.as_slice(),
901 &[ToolEditEvent::OldTextChunk {
902 edit_index: 0,
903 chunk: "line1\nline2".into(),
904 done: false,
905 }]
906 );
907
908 let events = parser.push_edits(&[PartialEdit {
909 old_text: Some("line1\nline2\nline3".into()),
910 new_text: Some("LINE1\n".into()),
911 }]);
912 assert_eq!(
913 events.as_slice(),
914 &[
915 ToolEditEvent::OldTextChunk {
916 edit_index: 0,
917 chunk: "\nline3".into(),
918 done: true,
919 },
920 ToolEditEvent::NewTextChunk {
921 edit_index: 0,
922 chunk: "LINE1\n".into(),
923 done: false,
924 },
925 ]
926 );
927
928 let events = parser.push_edits(&[PartialEdit {
929 old_text: Some("line1\nline2\nline3".into()),
930 new_text: Some("LINE1\nLINE2\nLINE3".into()),
931 }]);
932 assert_eq!(
933 events.as_slice(),
934 &[ToolEditEvent::NewTextChunk {
935 edit_index: 0,
936 chunk: "LINE2\nLINE3".into(),
937 done: false,
938 }]
939 );
940 }
941}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t