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
22 mod edit_parser;
33 #[cfg(test)]
44 mod evals;
5+pub mod reindent;
56 pub mod streaming_fuzzy_matcher;
67
78 use crate::{Template, Templates};
@@ -24,9 +25,10 @@ use language_model::{ · 16 unmodified lines
2425 LanguageModelToolChoice, MessageContent, Role,
2526 };
2627 use project::{AgentLocation, Project};
28+use reindent::{IndentDelta, Reindenter};
2729 use schemars::JsonSchema;
2830 use serde::{Deserialize, Serialize};
29−use std::{cmp, iter, mem, ops::Range, pin::Pin, sync::Arc, task::Poll};
31+use std::{mem, ops::Range, pin::Pin, sync::Arc, task::Poll};
3032 use streaming_diff::{CharOperation, StreamingDiff};
3133 use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
3234
@@ -553,15 +555,8 @@ impl EditAgent { · 520 unmodified lines
553555 let compute_edits = cx.background_spawn(async move {
554556 let buffer_start_indent = snapshot
555557 .line_indent_for_row(snapshot.offset_to_point(resolved_old_text.range.start).row);
556− let indent_delta = if buffer_start_indent.tabs > 0 {
557− IndentDelta::Tabs(
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− };
558+ let indent_delta =
559+ reindent::compute_indent_delta(buffer_start_indent, resolved_old_text.indent);
565560
566561 let old_text = snapshot
567562 .text_for_range(resolved_old_text.range.clone())
@@ -608,8 +603,7 @@ impl EditAgent { · 40 unmodified lines
608603 delta: IndentDelta,
609604 mut stream: impl Unpin + Stream<Item = Result<EditParserEvent>>,
610605 ) -> impl Stream<Item = Result<String>> {
611− let mut buffer = String::new();
612− let mut in_leading_whitespace = true;
606+ let mut reindenter = Reindenter::new(delta);
613607 let mut done = false;
614608 futures::stream::poll_fn(move |cx| {
615609 while !done {
@@ -622,55 +616,10 @@ impl EditAgent { · 6 unmodified lines
622616 _ => return Poll::Ready(None),
623617 };
624618
625− buffer.push_str(&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−
619+ let mut indented_new_text = reindenter.push(&chunk);
670620 // This was the last chunk, push all the buffered content as-is.
671621 if is_last_chunk {
672− indented_new_text.push_str(&buffer);
673− buffer.clear();
622+ indented_new_text.push_str(&reindenter.finish());
674623 done = true;
675624 }
676625
@@ -761,28 +710,6 @@ struct ResolvedOldText { · 84 unmodified lines
761710 indent: LineIndent,
762711 }
763712
764−#[derive(Copy, Clone, Debug)]
765−enum IndentDelta {
766− Spaces(isize),
767− Tabs(isize),
768−}
769−
770−impl 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−
786713 #[cfg(test)]
787714 mod tests {
788715 use super::*;
crates/agent/src/edit_agent/reindent.rs+214−0
@@ -0,0 +1,214 @@
1+use language::LineIndent;
2+use std::{cmp, iter};
3+
4+#[derive(Copy, Clone, Debug)]
5+pub enum IndentDelta {
6+ Spaces(isize),
7+ Tabs(isize),
8+}
9+
10+impl 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+
26+pub 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.
36+pub struct Reindenter {
37+ delta: IndentDelta,
38+ buffer: String,
39+ in_leading_whitespace: bool,
40+}
41+
42+impl 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)]
114+mod 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
1717 mod spawn_agent_tool;
1818 mod streaming_edit_file_tool;
1919 mod terminal_tool;
20+mod tool_edit_parser;
2021 mod tool_permissions;
2122 mod web_search_tool;
2223
crates/agent/src/tools/streaming_edit_file_tool.rs+934−1021
@@ -1,13 +1,17 @@
11 use super::edit_file_tool::EditFileTool;
22 use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
33 use super::save_file_tool::SaveFileTool;
4+use super::tool_edit_parser::{ToolEditEvent, ToolEditParser};
45 use crate::{
56 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+ },
711 };
812 use acp_thread::Diff;
913 use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10−use anyhow::{Context as _, Result, anyhow};
14+use anyhow::{Context as _, Result};
1115 use collections::HashSet;
1216 use futures::FutureExt as _;
1317 use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
@@ -15,16 +19,15 @@ use language::language_settings::{self, FormatOnSave}; · 1 unmodified line
1519 use language::{Buffer, LanguageRegistry};
1620 use language_model::LanguageModelToolResultContent;
1721 use project::lsp_store::{FormatTrigger, LspFormatTarget};
18−use project::{Project, ProjectPath};
22+use project::{AgentLocation, Project, ProjectPath};
1923 use schemars::JsonSchema;
2024 use serde::{Deserialize, Serialize};
21−use std::ops::Range;
2225 use std::path::PathBuf;
2326 use std::sync::Arc;
24−use text::{BufferSnapshot, ToOffset as _};
27+use streaming_diff::{CharOperation, StreamingDiff};
2528 use ui::SharedString;
2629 use util::rel_path::RelPath;
27−use util::{Deferred, ResultExt, debug_panic};
30+use util::{Deferred, ResultExt};
2831
2932 const DEFAULT_UI_TEXT: &str = "Editing file";
3033
@@ -70,14 +73,13 @@ pub struct StreamingEditFileToolInput { · 39 unmodified lines
7073 pub path: String,
7174
7275 /// The mode of operation on the file. Possible values:
73− /// - 'create': Create a new file if it doesn't exist. Requires 'content' field.
74− /// - 'overwrite': Replace the entire contents of an existing file. 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.
7577 /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.
7678 ///
7779 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
7880 pub mode: StreamingEditFileMode,
7981
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).
8183 /// This field should contain the entire file content.
8284 #[serde(default, skip_serializing_if = "Option::is_none")]
8385 pub content: Option<String>,
@@ -85,23 +87,22 @@ pub struct StreamingEditFileToolInput { · 1 unmodified line
8587 /// List of edit operations to apply sequentially (required for 'edit' mode).
8688 /// Each edit finds `old_text` in the file and replaces it with `new_text`.
8789 #[serde(default, skip_serializing_if = "Option::is_none")]
88− pub edits: Option<Vec<EditOperation>>,
90+ pub edits: Option<Vec<Edit>>,
8991 }
9092
9193 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
9294 #[serde(rename_all = "snake_case")]
9395 pub enum StreamingEditFileMode {
94− /// Create a new file if it doesn't exist
95− Create,
96− /// Replace the entire contents of an existing file
97− Overwrite,
96+ /// Overwrite the file with new content (replacing any existing content).
97+ /// If the file does not exist, it will be created.
98+ Write,
9899 /// Make granular edits to an existing file
99100 Edit,
100101 }
101102
102103 /// A single edit operation that replaces old text with new text
103104 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
104−pub struct EditOperation {
105+pub struct Edit {
105106 /// The exact text to find in the file. This will be matched using fuzzy matching
106107 /// to handle minor differences in whitespace or formatting.
107108 pub old_text: String,
@@ -118,271 +119,328 @@ struct StreamingEditFileToolPartialInput { · 10 unmodified lines
118119 #[serde(default)]
119120 mode: Option<StreamingEditFileMode>,
120121 #[serde(default)]
121− #[allow(dead_code)]
122122 content: Option<String>,
123123 #[serde(default)]
124− edits: Option<Vec<PartialEditOperation>>,
124+ edits: Option<Vec<PartialEdit>>,
125125 }
126126
127127 #[derive(Default, Debug, Deserialize)]
128−struct PartialEditOperation {
128+pub struct PartialEdit {
129129 #[serde(default)]
130− old_text: Option<String>,
130+ pub old_text: Option<String>,
131131 #[serde(default)]
132− new_text: Option<String>,
132+ pub new_text: Option<String>,
133133 }
134134
135−enum StreamingEditState {
136− Idle,
137− BufferResolved {
138− abs_path: PathBuf,
139− buffer: Entity<Buffer>,
135+#[derive(Debug, Serialize, Deserialize)]
136+#[serde(untagged)]
137+pub enum StreamingEditFileToolOutput {
138+ Success {
139+ #[serde(alias = "original_path")]
140+ input_path: PathBuf,
141+ new_text: String,
140142 old_text: Arc<String>,
141− diff: Entity<Diff>,
142− mode: StreamingEditFileMode,
143− last_content_len: usize,
144− edit_state: IncrementalEditState,
145− _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
143+ #[serde(default)]
144+ diff: String,
145+ },
146+ Error {
147+ error: String,
146148 },
147149 }
148150
149−#[derive(Default)]
150−struct IncrementalEditState {
151− in_progress_matcher: Option<StreamingFuzzyMatcher>,
152− last_old_text_len: usize,
153− applied_ranges: Vec<Range<text::Anchor>>,
151+impl StreamingEditFileToolOutput {
152+ pub fn error(error: impl Into<String>) -> Self {
153+ Self::Error {
154+ error: error.into(),
155+ }
156+ }
157+}
158+
159+impl 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+ }
154178 }
155179
156−impl IncrementalEditState {
157− fn applied_count(&self) -> usize {
158− self.applied_ranges.len()
180+impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
181+ fn from(output: StreamingEditFileToolOutput) -> Self {
182+ output.to_string().into()
159183 }
160184 }
161185
162−impl StreamingEditState {
163− async fn finalize(
164− &mut self,
165− input: StreamingEditFileToolInput,
166− tool: &StreamingEditFileTool,
186+pub struct StreamingEditFileTool {
187+ thread: WeakEntity<Thread>,
188+ language_registry: Arc<LanguageRegistry>,
189+ project: Entity<Project>,
190+}
191+
192+impl 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,
167209 event_stream: &ToolCallEventStream,
168− cx: &mut AsyncApp,
169− ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
170− let remaining_edits_start_ix = match self {
171− StreamingEditState::Idle => {
172− *self = Self::transition_to_buffer_resolved(
173− &input.path,
174− &input.display_description,
175− input.mode.clone(),
176− tool,
177− event_stream,
178− cx,
179− )
180− .await?;
181− 0
182− }
183− StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(),
184− };
210+ cx: &mut App,
211+ ) -> Task<Result<()>> {
212+ super::tool_permissions::authorize_file_edit(
213+ EditFileTool::NAME,
214+ path,
215+ description,
216+ &self.thread,
217+ event_stream,
218+ cx,
219+ )
220+ }
185221
186− let StreamingEditState::BufferResolved {
187− buffer,
188− old_text,
189− diff,
190− abs_path,
191− ..
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− };
222+ fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
223+ self.project.update(cx, |project, cx| {
224+ project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
225+ });
226+ }
227+}
199228
200− let result: anyhow::Result<StreamingEditFileToolOutput> = async {
201− let action_log = tool
202− .thread
203− .read_with(cx, |thread, _cx| thread.action_log().clone())?;
229+impl AgentTool for StreamingEditFileTool {
230+ type Input = StreamingEditFileToolInput;
231+ type Output = StreamingEditFileToolOutput;
204232
205− match input.mode {
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− }
233+ const NAME: &'static str = "streaming_edit_file";
239234
240− let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
241− let settings = language_settings::language_settings(
242− buffer.language().map(|l| l.name()),
243− buffer.file(),
244− cx,
245− );
246− settings.format_on_save != FormatOnSave::Off
247− });
235+ fn supports_input_streaming() -> bool {
236+ true
237+ }
248238
249− if format_on_save_enabled {
250− action_log.update(cx, |log, cx| {
251− log.buffer_edited(buffer.clone(), cx);
252− });
239+ fn kind() -> acp::ToolKind {
240+ acp::ToolKind::Edit
241+ }
253242
254− let format_task = tool.project.update(cx, |project, cx| {
255− project.format(
256− HashSet::from_iter([buffer.clone()]),
257− LspFormatTarget::Buffers,
258− false,
259− FormatTrigger::Save,
260− cx,
261− )
262− });
263− futures::select! {
264− result = format_task.fuse() => { result.log_err(); },
265− _ = event_stream.cancelled_by_user().fuse() => {
266− anyhow::bail!("Edit cancelled by user");
243+ fn initial_title(
244+ &self,
245+ input: Result<Self::Input, serde_json::Value>,
246+ cx: &mut App,
247+ ) -> SharedString {
248+ match input {
249+ Ok(input) => self
250+ .project
251+ .read(cx)
252+ .find_project_path(&input.path, cx)
253+ .and_then(|project_path| {
254+ self.project
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();
267278 }
268− };
269− }
270279
271− let save_task = tool
272− .project
273− .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
274− futures::select! {
275− result = save_task.fuse() => { result?; },
276− _ = event_stream.cancelled_by_user().fuse() => {
277− anyhow::bail!("Edit cancelled by user");
280+ let description = input.display_description.unwrap_or_default();
281+ let description = description.trim();
282+ if !description.is_empty() {
283+ return description.to_string().into();
284+ }
278285 }
279− };
280−
281− action_log.update(cx, |log, cx| {
282− log.buffer_edited(buffer.clone(), cx);
283− });
284286
285− if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
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− })?;
287+ DEFAULT_UI_TEXT.into()
293288 }
289+ }
290+ }
294291
295− let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
296− let (new_text, unified_diff) = cx
297− .background_spawn({
298− let new_snapshot = new_snapshot.clone();
299− let old_text = old_text.clone();
300− async move {
301− let new_text = new_snapshot.text();
302− let diff = language::unified_diff(&old_text, &new_text);
303− (new_text, diff)
292+ fn run(
293+ self: Arc<Self>,
294+ mut input: ToolInput<Self::Input>,
295+ event_stream: ToolCallEventStream,
296+ cx: &mut App,
297+ ) -> Task<Result<Self::Output, Self::Output>> {
298+ cx.spawn(async move |cx: &mut AsyncApp| {
299+ let mut state: Option<EditSession> = None;
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+ }
304325 }
305− })
306− .await;
326+ _ = event_stream.cancelled_by_user().fuse() => {
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}")))?;
307336
308− let output = StreamingEditFileToolOutput::Success {
309− input_path: PathBuf::from(input.path),
310− new_text,
311− old_text: old_text.clone(),
312− diff: unified_diff,
337+ let mut state = if let Some(state) = state {
338+ state
339+ } else {
340+ EditSession::new(
341+ &full_input.path,
342+ &full_input.display_description,
343+ full_input.mode.clone(),
344+ &self,
345+ &event_stream,
346+ cx,
347+ )
348+ .await?
313349 };
314− Ok(output)
315− }
316− .await;
317− result.map_err(|e| StreamingEditFileToolOutput::Error {
318− error: e.to_string(),
350+ state.finalize(full_input, &self, &event_stream, cx).await
319351 })
320352 }
321353
322− async fn process(
323− &mut self,
324− partial: StreamingEditFileToolPartialInput,
325− tool: &StreamingEditFileTool,
326− event_stream: &ToolCallEventStream,
327− cx: &mut AsyncApp,
328− ) -> Result<(), StreamingEditFileToolOutput> {
329− match self {
330− Self::Idle => {
331− if let Some(path_str) = partial.path
332− && let Some(display_description) = partial.display_description
333− && let Some(mode) = partial.mode
334− {
335− *self = Self::transition_to_buffer_resolved(
336− &path_str,
337− &display_description,
338− mode,
339− tool,
340− event_stream,
354+ fn replay(
355+ &self,
356+ _input: Self::Input,
357+ output: Self::Output,
358+ event_stream: ToolCallEventStream,
359+ cx: &mut App,
360+ ) -> Result<()> {
361+ match output {
362+ StreamingEditFileToolOutput::Success {
363+ input_path,
364+ old_text,
365+ new_text,
366+ ..
367+ } => {
368+ event_stream.update_diff(cx.new(|cx| {
369+ Diff::finalized(
370+ input_path.to_string_lossy().into_owned(),
371+ Some(old_text.to_string()),
372+ new_text,
373+ self.language_registry.clone(),
341374 cx,
342375 )
343− .await?;
344− }
376+ }));
377+ Ok(())
345378 }
346− Self::BufferResolved {
347− abs_path,
348− buffer,
349− edit_state,
350− diff,
351− mode,
352− last_content_len,
353− ..
354− } => match mode {
355− StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
356− if let Some(content) = &partial.content {
357− Self::process_streaming_content(
358− buffer,
359− diff,
360− last_content_len,
361− content,
362− cx,
363− )?;
364− }
365− }
366− StreamingEditFileMode::Edit => {
367− if let Some(edits) = partial.edits {
368− Self::process_streaming_edits(
369− buffer,
370− diff,
371− edit_state,
372− &edits,
373− abs_path,
374− tool,
375− event_stream,
376− cx,
377− )?;
378− }
379− }
380− },
379+ StreamingEditFileToolOutput::Error { .. } => Ok(()),
380+ }
381+ }
382+}
383+
384+pub struct EditSession {
385+ abs_path: PathBuf,
386+ buffer: Entity<Buffer>,
387+ old_text: Arc<String>,
388+ diff: Entity<Diff>,
389+ mode: StreamingEditFileMode,
390+ parser: ToolEditParser,
391+ pipeline: EditPipeline,
392+ _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
393+}
394+
395+struct EditPipeline {
396+ edits: Vec<EditPipelineEntry>,
397+ content_written: bool,
398+}
399+
400+enum EditPipelineEntry {
401+ ResolvingOldText {
402+ matcher: StreamingFuzzyMatcher,
403+ },
404+ StreamingNewText {
405+ streaming_diff: StreamingDiff,
406+ edit_cursor: usize,
407+ reindenter: Reindenter,
408+ original_snapshot: text::BufferSnapshot,
409+ },
410+ Done,
411+}
412+
413+impl EditPipeline {
414+ fn new() -> Self {
415+ Self {
416+ edits: Vec::new(),
417+ content_written: false,
381418 }
382− Ok(())
383419 }
384420
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.
437+fn 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+
442+impl EditSession {
443+ async fn new(
386444 path_str: &str,
387445 display_description: &str,
388446 mode: StreamingEditFileMode,
@@ -393,15 +451,13 @@ impl StreamingEditState { · 4 unmodified lines
393451 let path = PathBuf::from(path_str);
394452 let project_path = cx
395453 .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx))
396− .map_err(|e| StreamingEditFileToolOutput::Error {
397− error: e.to_string(),
398− })?;
454+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
399455
400456 let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
401457 else {
402− return Err(StreamingEditFileToolOutput::Error {
403− error: format!("File '{path_str}' does not exist"),
404− });
458+ return Err(StreamingEditFileToolOutput::error(format!(
459+ "Worktree at '{path_str}' does not exist"
460+ )));
405461 };
406462
407463 event_stream.update_fields(
@@ -410,17 +466,13 @@ impl StreamingEditState { · 2 unmodified lines
410466
411467 cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
412468 .await
413− .map_err(|e| StreamingEditFileToolOutput::Error {
414− error: e.to_string(),
415− })?;
469+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
416470
417471 let buffer = tool
418472 .project
419473 .update(cx, |project, cx| project.open_buffer(project_path, cx))
420474 .await
421− .map_err(|e| StreamingEditFileToolOutput::Error {
422− error: e.to_string(),
423− })?;
475+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
424476
425477 ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
426478
@@ -434,6 +486,14 @@ impl StreamingEditState { · 7 unmodified lines
434486 }
435487 }) as Box<dyn FnOnce()>);
436488
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+
437497 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
438498 let old_text = cx
439499 .background_spawn({
@@ -442,205 +502,497 @@ impl StreamingEditState { · 2 unmodified lines
442502 })
443503 .await;
444504
445− Ok(Self::BufferResolved {
505+ Ok(Self {
446506 abs_path,
447507 buffer,
448508 old_text,
449509 diff,
450510 mode,
451− last_content_len: 0,
452− edit_state: IncrementalEditState::default(),
511+ parser: ToolEditParser::default(),
512+ pipeline: EditPipeline::new(),
453513 _finalize_diff_guard: finalize_diff_guard,
454514 })
455515 }
456516
457− fn process_streaming_content(
458− buffer: &Entity<Buffer>,
459− diff: &Entity<Diff>,
460− last_content_len: &mut usize,
461− content: &str,
517+ async fn finalize(
518+ &mut self,
519+ input: StreamingEditFileToolInput,
520+ tool: &StreamingEditFileTool,
521+ event_stream: &ToolCallEventStream,
462522 cx: &mut AsyncApp,
463− ) -> Result<(), StreamingEditFileToolOutput> {
464− let new_len = content.len();
465− if new_len > *last_content_len {
466− let new_chunk = &content[*last_content_len..];
467− cx.update(|cx| {
468− buffer.update(cx, |buffer, cx| {
469− // On the first update, replace the entire buffer (handles Overwrite
470− // clearing existing content). For Create the buffer is already empty
471− // so 0..0 is a no-op range prefix.
472− let insert_at = if *last_content_len == 0 {
473− 0..buffer.len()
474− } else {
475− let len = buffer.len();
476− len..len
477− };
478− buffer.edit([(insert_at, new_chunk)], None, cx);
523+ ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
524+ let Self {
525+ buffer,
526+ old_text,
527+ diff,
528+ abs_path,
529+ parser,
530+ pipeline,
531+ ..
532+ } = self;
533+
534+ let action_log = tool
535+ .thread
536+ .read_with(cx, |thread, _cx| thread.action_log().clone())
537+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
538+
539+ match input.mode {
540+ StreamingEditFileMode::Write => {
541+ action_log.update(cx, |log, cx| {
542+ log.buffer_created(buffer.clone(), cx);
479543 });
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);
480598 });
481− *last_content_len = new_len;
482599
483− let anchor_range = buffer.read_with(cx, |buffer, _cx| {
484− buffer.anchor_range_between(0..buffer.len())
600+ let format_task = tool.project.update(cx, |project, cx| {
601+ project.format(
602+ HashSet::from_iter([buffer.clone()]),
603+ LspFormatTarget::Buffers,
604+ false,
605+ FormatTrigger::Save,
606+ cx,
607+ )
485608 });
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+ }
487703 }
488704 Ok(())
489705 }
490706
491− fn process_streaming_edits(
707+ fn process_events(
708+ events: &[ToolEditEvent],
492709 buffer: &Entity<Buffer>,
493710 diff: &Entity<Diff>,
494− edit_state: &mut IncrementalEditState,
495− edits: &[PartialEditOperation],
711+ pipeline: &mut EditPipeline,
496712 abs_path: &PathBuf,
497713 tool: &StreamingEditFileTool,
498714 event_stream: &ToolCallEventStream,
499715 cx: &mut AsyncApp,
500716 ) -> Result<(), StreamingEditFileToolOutput> {
501− if edits.is_empty() {
502− return Ok(());
503− }
717+ for event in events {
718+ match event {
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+ }
504739
505− // Edits at indices applied_count..edits.len()-1 are newly complete
506− // (a subsequent edit exists, proving the LLM moved on).
507− // The last edit (edits.len()-1) is potentially still in progress.
508− let completed_count = edits.len().saturating_sub(1);
740+ ToolEditEvent::OldTextChunk {
741+ edit_index,
742+ chunk,
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+ }
509765
510− // Apply newly-complete edits
511− while edit_state.applied_count() < completed_count {
512− let edit_index = edit_state.applied_count();
513− let partial_edit = &edits[edit_index];
766+ ToolEditEvent::OldTextChunk {
767+ edit_index,
768+ chunk,
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+ };
514778
515− let old_text = partial_edit.old_text.clone().ok_or_else(|| {
516− StreamingEditFileToolOutput::Error {
517− error: format!("Edit at index {} is missing old_text.", edit_index),
518− }
519− })?;
520− let new_text = partial_edit.new_text.clone().unwrap_or_default();
779+ if !chunk.is_empty() {
780+ matcher.push(chunk, None);
781+ }
782+ let matches = matcher.finish();
783+
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+ }
521806
522− edit_state.in_progress_matcher = None;
523− edit_state.last_old_text_len = 0;
807+ let range = matches.into_iter().next().expect("checked len above");
524808
525− let edit_op = EditOperation {
526− old_text: old_text.clone(),
527− new_text: new_text.clone(),
528− };
809+ let anchor_range = buffer
810+ .read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
811+ diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
812+
813+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
529814
530− let action_log = tool
531− .thread
532− .read_with(cx, |thread, _cx| thread.action_log().clone())
533− .ok();
815+ let line = snapshot.offset_to_point(range.start).row;
816+ event_stream.update_fields(
817+ ToolCallUpdateFields::new()
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+ };
534841
535− // On the first edit, mark the buffer as read
536− if edit_state.applied_count() == 0 {
537− if let Some(action_log) = &action_log {
538− action_log.update(cx, |log, cx| {
539− log.buffer_read(buffer.clone(), cx);
842+ cx.update(|cx| {
843+ let position = buffer.read(cx).anchor_before(range.end);
844+ tool.set_agent_location(buffer.downgrade(), position, cx);
540845 });
541846 }
542− }
543847
544− let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
545−
546− let (range, new_text) =
547− match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) {
548− Ok(resolved) => resolved,
549− Err(EditResolveError::NotFound) => {
550− return Err(StreamingEditFileToolOutput::Error {
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− });
848+ ToolEditEvent::NewTextChunk {
849+ edit_index,
850+ chunk,
851+ done: false,
852+ } => {
853+ if *edit_index >= pipeline.edits.len() {
854+ continue;
558855 }
559− Err(EditResolveError::Ambiguous(ranges)) => {
560− let lines = ranges
561− .iter()
562− .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
563− .collect::<Vec<_>>()
564− .join(", ");
565− return Err(StreamingEditFileToolOutput::Error {
566− error: format!(
567− "Edit {} matched multiple locations in the file at lines: {}. \
568− Please provide more context in old_text to uniquely \
569− identify the location.",
570− edit_index, lines
571− ),
572− });
856+ let EditPipelineEntry::StreamingNewText {
857+ streaming_diff,
858+ edit_cursor,
859+ reindenter,
860+ original_snapshot,
861+ ..
862+ } = &mut pipeline.edits[*edit_index]
863+ else {
864+ continue;
865+ };
866+
867+ let reindented = reindenter.push(chunk);
868+ if reindented.is_empty() {
869+ continue;
573870 }
574− };
575871
576− for previous_range in &edit_state.applied_ranges {
577− let previous_start = previous_range.start.to_offset(&snapshot);
578− let previous_end = previous_range.end.to_offset(&snapshot);
579− if range.start < previous_end && previous_start < range.end {
580− let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1;
581− let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1;
582− let later_start_line = snapshot.offset_to_point(range.start).row + 1;
583− let later_end_line = snapshot.offset_to_point(range.end).row + 1;
584− return Err(StreamingEditFileToolOutput::Error {
585− error: format!(
586− "Conflicting edit ranges detected: lines {}-{} \
587− conflicts with lines {}-{}. Conflicting edit \
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− ),
872+ let char_ops = streaming_diff.push_new(&reindented);
873+ Self::apply_char_operations(
874+ &char_ops,
875+ buffer,
876+ original_snapshot,
877+ edit_cursor,
878+ cx,
879+ );
880+
881+ let position = original_snapshot.anchor_before(*edit_cursor);
882+ cx.update(|cx| {
883+ tool.set_agent_location(buffer.downgrade(), position, cx);
592884 });
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+ }
593895 }
594− }
595896
596− let anchor_range =
597− buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
598− edit_state.applied_ranges.push(anchor_range);
897+ ToolEditEvent::NewTextChunk {
898+ edit_index,
899+ chunk,
900+ done: true,
901+ } => {
902+ if *edit_index >= pipeline.edits.len() {
903+ continue;
904+ }
599905
600− let line = snapshot.offset_to_point(range.start).row;
601− event_stream.update_fields(
602− ToolCallUpdateFields::new()
603− .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
604− );
906+ let EditPipelineEntry::StreamingNewText {
907+ mut streaming_diff,
908+ mut edit_cursor,
909+ mut reindenter,
910+ original_snapshot,
911+ } = std::mem::replace(
912+ &mut pipeline.edits[*edit_index],
913+ EditPipelineEntry::Done,
914+ )
915+ else {
916+ continue;
917+ };
605918
606− if let Some(action_log) = action_log {
607− cx.update(|cx| {
608− buffer.update(cx, |buffer, cx| {
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− }
919+ // Flush any remaining reindent buffer + final chunk.
920+ let mut final_text = reindenter.push(chunk);
921+ final_text.push_str(&reindenter.finish());
617922
618− // Feed the in-progress last edit's old_text to the matcher for live preview
619− if let Some(partial_edit) = edits.last() {
620− if let Some(old_text) = &partial_edit.old_text {
621− let old_text_len = old_text.len();
622− if old_text_len > edit_state.last_old_text_len {
623− let new_chunk = &old_text[edit_state.last_old_text_len..];
923+ if !final_text.is_empty() {
924+ let char_ops = streaming_diff.push_new(&final_text);
925+ Self::apply_char_operations(
926+ &char_ops,
927+ buffer,
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+ );
624942
625− let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| {
626− let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
627− StreamingFuzzyMatcher::new(snapshot)
943+ let position = original_snapshot.anchor_before(edit_cursor);
944+ cx.update(|cx| {
945+ tool.set_agent_location(buffer.downgrade(), position, cx);
628946 });
629947
630− if let Some(match_range) = matcher.push(new_chunk, None) {
631− let anchor_range = buffer.read_with(cx, |buffer, _cx| {
632− buffer.anchor_range_between(match_range.clone())
948+ let action_log = tool
949+ .thread
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);
633955 });
634− diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
635956 }
636−
637− edit_state.last_old_text_len = old_text_len;
638957 }
639958 }
640959 }
641−
642960 Ok(())
643961 }
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+ }
644996 }
645997
646998 fn ensure_buffer_saved(
@@ -670,396 +1022,40 @@ fn ensure_buffer_saved( · 23 unmodified lines
6701022 if is_dirty {
6711023 let message = match (has_save_tool, has_restore_tool) {
6721024 (true, true) => {
673− "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. \
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."
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)]
712−pub 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−
726−impl 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−
747−impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
748− fn from(output: StreamingEditFileToolOutput) -> Self {
749− output.to_string().into()
750− }
751−}
752−
753−pub struct StreamingEditFileTool {
754− thread: WeakEntity<Thread>,
755− language_registry: Arc<LanguageRegistry>,
756− project: Entity<Project>,
757−}
758−
759−impl 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−
790−impl 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−
918−fn 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));
1025+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
1026+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, 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."
9361028 }
937− Err(EditResolveError::NotFound) => {
938− failed_edits.push(index);
1029+ (true, false) => {
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."
9391033 }
940− Err(EditResolveError::Ambiguous(ranges)) => {
941− ambiguous_edits.push((index, ranges));
1034+ (false, true) => {
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."
9421038 }
943− }
944− }
945−
946− if !failed_edits.is_empty() {
947− let indices = failed_edits
948− .iter()
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− );
1039+ (false, false) => {
1040+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
1041+ then ask them to save or revert the file manually and inform you when it's ok to proceed."
1042+ }
1043+ };
1044+ return Err(StreamingEditFileToolOutput::error(message));
9881045 }
9891046
990− for window in edits_sorted.windows(2) {
991− if let [(earlier_range, _), (later_range, _)] = window
992− && (earlier_range.end > later_range.start || earlier_range.start == later_range.start)
993− {
994− let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1;
995− let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1;
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− );
1047+ if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
1048+ if current != last_read {
1049+ return Err(StreamingEditFileToolOutput::error(
1050+ "The file has been modified since you last read it. \
1051+ Please read the file again to get the current state before editing it.",
1052+ ));
10061053 }
10071054 }
10081055
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−
10261056 Ok(())
10271057 }
10281058
1029−enum 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.
1037−fn 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−
10631059 fn resolve_path(
10641060 mode: StreamingEditFileMode,
10651061 path: &PathBuf,
@@ -1069,7 +1065,7 @@ fn resolve_path( · 3 unmodified lines
10691065 let project = project.read(cx);
10701066
10711067 match mode {
1072− StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => {
1068+ StreamingEditFileMode::Edit => {
10731069 let path = project
10741070 .find_project_path(&path, cx)
10751071 .context("Can't edit file: path not found")?;
@@ -1081,13 +1077,12 @@ fn resolve_path( · 5 unmodified lines
10811077 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
10821078 Ok(path)
10831079 }
1084−
1085− StreamingEditFileMode::Create => {
1086− if let Some(path) = project.find_project_path(&path, cx) {
1087− anyhow::ensure!(
1088− project.entry_for_path(&path, cx).is_none(),
1089− "Can't create file: file already exists"
1090− );
1080+ StreamingEditFileMode::Write => {
1081+ if let Some(path) = project.find_project_path(&path, cx)
1082+ && let Some(entry) = project.entry_for_path(&path, cx)
1083+ {
1084+ anyhow::ensure!(entry.is_file(), "Can't write to file: path is a directory");
1085+ return Ok(path);
10911086 }
10921087
10931088 let parent_path = path.parent().context("Can't create file: incorrect path")?;
@@ -1162,7 +1157,7 @@ mod tests { · 68 unmodified lines
11621157 let input = StreamingEditFileToolInput {
11631158 display_description: "Create new file".into(),
11641159 path: "root/dir/new_file.txt".into(),
1165− mode: StreamingEditFileMode::Create,
1160+ mode: StreamingEditFileMode::Write,
11661161 content: Some("Hello, World!".into()),
11671162 edits: None,
11681163 };
@@ -1214,7 +1209,7 @@ mod tests { · 45 unmodified lines
12141209 let input = StreamingEditFileToolInput {
12151210 display_description: "Overwrite file".into(),
12161211 path: "root/file.txt".into(),
1217− mode: StreamingEditFileMode::Overwrite,
1212+ mode: StreamingEditFileMode::Write,
12181213 content: Some("new content".into()),
12191214 edits: None,
12201215 };
@@ -1276,7 +1271,7 @@ mod tests { · 55 unmodified lines
12761271 path: "root/file.txt".into(),
12771272 mode: StreamingEditFileMode::Edit,
12781273 content: None,
1279− edits: Some(vec![EditOperation {
1274+ edits: Some(vec![Edit {
12801275 old_text: "line 2".into(),
12811276 new_text: "modified line 2".into(),
12821277 }]),
@@ -1301,7 +1296,7 @@ mod tests { · 18 unmodified lines
13011296 }
13021297
13031298 #[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) {
13051300 init_test(cx);
13061301
13071302 let fs = project::FakeFs::new(cx.executor());
@@ -1336,11 +1331,11 @@ mod tests { · 28 unmodified lines
13361331 mode: StreamingEditFileMode::Edit,
13371332 content: None,
13381333 edits: Some(vec![
1339− EditOperation {
1334+ Edit {
13401335 old_text: "line 5".into(),
13411336 new_text: "modified line 5".into(),
13421337 },
1343− EditOperation {
1338+ Edit {
13441339 old_text: "line 1".into(),
13451340 new_text: "modified line 1".into(),
13461341 },
@@ -1404,11 +1399,11 @@ mod tests { · 57 unmodified lines
14041399 mode: StreamingEditFileMode::Edit,
14051400 content: None,
14061401 edits: Some(vec![
1407− EditOperation {
1402+ Edit {
14081403 old_text: "line 2".into(),
14091404 new_text: "modified line 2".into(),
14101405 },
1411− EditOperation {
1406+ Edit {
14121407 old_text: "line 3".into(),
14131408 new_text: "modified line 3".into(),
14141409 },
@@ -1472,11 +1467,11 @@ mod tests { · 57 unmodified lines
14721467 mode: StreamingEditFileMode::Edit,
14731468 content: None,
14741469 edits: Some(vec![
1475− EditOperation {
1470+ Edit {
14761471 old_text: "line 1".into(),
14771472 new_text: "modified line 1".into(),
14781473 },
1479− EditOperation {
1474+ Edit {
14801475 old_text: "line 5".into(),
14811476 new_text: "modified line 5".into(),
14821477 },
@@ -1533,7 +1528,7 @@ mod tests { · 50 unmodified lines
15331528 path: "root/nonexistent_file.txt".into(),
15341529 mode: StreamingEditFileMode::Edit,
15351530 content: None,
1536− edits: Some(vec![EditOperation {
1531+ edits: Some(vec![Edit {
15371532 old_text: "foo".into(),
15381533 new_text: "bar".into(),
15391534 }]),
@@ -1587,7 +1582,7 @@ mod tests { · 47 unmodified lines
15871582 path: "root/file.txt".into(),
15881583 mode: StreamingEditFileMode::Edit,
15891584 content: None,
1590− edits: Some(vec![EditOperation {
1585+ edits: Some(vec![Edit {
15911586 old_text: "nonexistent text that is not in the file".into(),
15921587 new_text: "replacement".into(),
15931588 }]),
@@ -1614,79 +1609,6 @@ mod tests { · 20 unmodified lines
16141609 );
16151610 }
16161611
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−
16901612 #[gpui::test]
16911613 async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) {
16921614 init_test(cx);
@@ -1809,7 +1731,7 @@ mod tests { · 116 unmodified lines
18091731 sender.send_partial(json!({
18101732 "display_description": "Overwrite file",
18111733 "path": "root/file.txt",
1812− "mode": "overwrite"
1734+ "mode": "write"
18131735 }));
18141736 cx.run_until_parked();
18151737
@@ -1817,7 +1739,7 @@ mod tests { · 1 unmodified line
18171739 sender.send_final(json!({
18181740 "display_description": "Overwrite file",
18191741 "path": "root/file.txt",
1820− "mode": "overwrite",
1742+ "mode": "write",
18211743 "content": "new content"
18221744 }));
18231745
@@ -2026,14 +1948,14 @@ mod tests { · 202 unmodified lines
20261948 sender.send_partial(json!({
20271949 "display_description": "Create new file",
20281950 "path": "root/dir/new_file.txt",
2029− "mode": "create"
1951+ "mode": "write"
20301952 }));
20311953 cx.run_until_parked();
20321954
20331955 sender.send_partial(json!({
20341956 "display_description": "Create new file",
20351957 "path": "root/dir/new_file.txt",
2036− "mode": "create",
1958+ "mode": "write",
20371959 "content": "Hello, "
20381960 }));
20391961 cx.run_until_parked();
@@ -2042,7 +1964,7 @@ mod tests { · 2 unmodified lines
20421964 sender.send_final(json!({
20431965 "display_description": "Create new file",
20441966 "path": "root/dir/new_file.txt",
2045− "mode": "create",
1967+ "mode": "write",
20461968 "content": "Hello, World!"
20471969 }));
20481970
@@ -2304,14 +2226,16 @@ mod tests { · 255 unmodified lines
23042226 }));
23052227 cx.run_until_parked();
23062228
2307− // Verify edit 1 applied
2229+ // 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).
23082232 let buffer_text = project.update(cx, |project, cx| {
23092233 let pp = project
23102234 .find_project_path(&PathBuf::from("root/file.txt"), cx)
23112235 .unwrap();
23122236 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
23132237 });
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"));
23152239
23162240 // Edit 3 appears — edit 2 is now complete and should be applied
23172241 sender.send_partial(json!({
@@ -2326,14 +2250,15 @@ mod tests { · 8 unmodified lines
23262250 }));
23272251 cx.run_until_parked();
23282252
2329− // Verify edits 1 and 2 both applied
2253+ // 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.
23302255 let buffer_text = project.update(cx, |project, cx| {
23312256 let pp = project
23322257 .find_project_path(&PathBuf::from("root/file.txt"), cx)
23332258 .unwrap();
23342259 project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text())
23352260 });
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"));
23372262
23382263 // Send final
23392264 sender.send_final(json!({
@@ -2466,82 +2391,6 @@ mod tests { · 126 unmodified lines
24662391 );
24672392 }
24682393
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−
25452394 #[gpui::test]
25462395 async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) {
25472396 init_test(cx);
@@ -2590,7 +2439,10 @@ mod tests { · 42 unmodified lines
25902439 }));
25912440 cx.run_until_parked();
25922441
2593− // Buffer should NOT be modified — the single edit is still in-progress
2442+ // 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).
25942446 let buffer_text = project.update(cx, |project, cx| {
25952447 let pp = project
25962448 .find_project_path(&PathBuf::from("root/file.txt"), cx)
@@ -2599,8 +2451,8 @@ mod tests { · 2 unmodified lines
25992451 });
26002452 assert_eq!(
26012453 buffer_text.as_deref(),
2602− Some("hello world\n"),
2603− "Single in-progress edit should not be applied during streaming"
2454+ Some("goodbye worldhello world\n"),
2455+ "In-progress streaming diff: new text inserted, old text not yet removed"
26042456 );
26052457
26062458 // Send final — the edit is applied during finalization
@@ -2795,12 +2647,12 @@ mod tests { · 188 unmodified lines
27952647 sender.send_partial(json!({
27962648 "display_description": "Create",
27972649 "path": "root/dir/new.txt",
2798− "mode": "create"
2650+ "mode": "write"
27992651 }));
28002652 sender.send_final(json!({
28012653 "display_description": "Create",
28022654 "path": "root/dir/new.txt",
2803− "mode": "create",
2655+ "mode": "write",
28042656 "content": "streamed content"
28052657 }));
28062658
@@ -2813,7 +2665,7 @@ mod tests { · 6 unmodified lines
28132665
28142666 #[gpui::test]
28152667 async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {
2816− let mode = StreamingEditFileMode::Create;
2668+ let mode = StreamingEditFileMode::Write;
28172669
28182670 let result = test_resolve_path(&mode, "root/new.txt", cx);
28192671 assert_resolved_path_eq(result.await, rel_path("new.txt"));
@@ -2825,9 +2677,12 @@ mod tests { · 5 unmodified lines
28252677 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
28262678
28272679 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);
28282683 assert_eq!(
28292684 result.await.unwrap_err().to_string(),
2830− "Can't create file: file already exists"
2685+ "Can't write to file: path is a directory"
28312686 );
28322687
28332688 let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);
@@ -3003,14 +2858,14 @@ mod tests { · 169 unmodified lines
30032858 sender.send_partial(json!({
30042859 "display_description": "Create main function",
30052860 "path": "root/src/main.rs",
3006− "mode": "overwrite"
2861+ "mode": "write"
30072862 }));
30082863 cx.run_until_parked();
30092864
30102865 sender.send_final(json!({
30112866 "display_description": "Create main function",
30122867 "path": "root/src/main.rs",
3013− "mode": "overwrite",
2868+ "mode": "write",
30142869 "content": UNFORMATTED_CONTENT
30152870 }));
30162871
@@ -3060,14 +2915,14 @@ mod tests { · 43 unmodified lines
30602915 sender.send_partial(json!({
30612916 "display_description": "Update main function",
30622917 "path": "root/src/main.rs",
3063− "mode": "overwrite"
2918+ "mode": "write"
30642919 }));
30652920 cx.run_until_parked();
30662921
30672922 sender.send_final(json!({
30682923 "display_description": "Update main function",
30692924 "path": "root/src/main.rs",
3070− "mode": "overwrite",
2925+ "mode": "write",
30712926 "content": UNFORMATTED_CONTENT
30722927 }));
30732928
@@ -3136,7 +2991,7 @@ mod tests { · 62 unmodified lines
31362991 let input = StreamingEditFileToolInput {
31372992 display_description: "Create main function".into(),
31382993 path: "root/src/main.rs".into(),
3139− mode: StreamingEditFileMode::Overwrite,
2994+ mode: StreamingEditFileMode::Write,
31402995 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
31412996 edits: None,
31422997 };
@@ -3183,7 +3038,7 @@ mod tests { · 40 unmodified lines
31833038 let input = StreamingEditFileToolInput {
31843039 display_description: "Update main function".into(),
31853040 path: "root/src/main.rs".into(),
3186− mode: StreamingEditFileMode::Overwrite,
3041+ mode: StreamingEditFileMode::Write,
31873042 content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()),
31883043 edits: None,
31893044 };
@@ -3904,11 +3759,7 @@ mod tests { · 714 unmodified lines
39043759 language_registry,
39053760 ));
39063761
3907− let modes = vec![
3908− StreamingEditFileMode::Edit,
3909− StreamingEditFileMode::Create,
3910− StreamingEditFileMode::Overwrite,
3911− ];
3762+ let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write];
39123763
39133764 for _mode in modes {
39143765 // Test .zed path with different modes
@@ -4061,7 +3912,7 @@ mod tests { · 146 unmodified lines
40613912 ToolInput::resolved(StreamingEditFileToolInput {
40623913 display_description: "Edit file".into(),
40633914 path: path!("/main.rs").into(),
4064− mode: StreamingEditFileMode::Overwrite,
3915+ mode: StreamingEditFileMode::Write,
40653916 content: Some("new content".into()),
40663917 edits: None,
40673918 }),
@@ -4090,7 +3941,7 @@ mod tests { · 22 unmodified lines
40903941 ToolInput::resolved(StreamingEditFileToolInput {
40913942 display_description: "Edit file".into(),
40923943 path: path!("/main.rs").into(),
4093− mode: StreamingEditFileMode::Overwrite,
3944+ mode: StreamingEditFileMode::Write,
40943945 content: Some("dropped content".into()),
40953946 edits: None,
40963947 }),
@@ -4171,7 +4022,7 @@ mod tests { · 74 unmodified lines
41714022 path: "root/test.txt".into(),
41724023 mode: StreamingEditFileMode::Edit,
41734024 content: None,
4174− edits: Some(vec![EditOperation {
4025+ edits: Some(vec![Edit {
41754026 old_text: "original content".into(),
41764027 new_text: "modified content".into(),
41774028 }]),
@@ -4196,7 +4047,7 @@ mod tests { · 18 unmodified lines
41964047 path: "root/test.txt".into(),
41974048 mode: StreamingEditFileMode::Edit,
41984049 content: None,
4199− edits: Some(vec![EditOperation {
4050+ edits: Some(vec![Edit {
42004051 old_text: "modified content".into(),
42014052 new_text: "further modified content".into(),
42024053 }]),
@@ -4305,7 +4156,7 @@ mod tests { · 102 unmodified lines
43054156 path: "root/test.txt".into(),
43064157 mode: StreamingEditFileMode::Edit,
43074158 content: None,
4308− edits: Some(vec![EditOperation {
4159+ edits: Some(vec![Edit {
43094160 old_text: "externally modified content".into(),
43104161 new_text: "new content".into(),
43114162 }]),
@@ -4409,7 +4260,7 @@ mod tests { · 97 unmodified lines
44094260 path: "root/test.txt".into(),
44104261 mode: StreamingEditFileMode::Edit,
44114262 content: None,
4412− edits: Some(vec![EditOperation {
4263+ edits: Some(vec![Edit {
44134264 old_text: "original content".into(),
44144265 new_text: "new content".into(),
44154266 }]),
@@ -4441,15 +4292,14 @@ mod tests { · 25 unmodified lines
44414292 }
44424293
44434294 #[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) {
44454296 init_test(cx);
44464297
44474298 let fs = project::FakeFs::new(cx.executor());
4448− // The file content is crafted so that edit 1's replacement still
4449− // contains the old_text of edit 2 as a contiguous substring.
4450− // Without early overlap detection, edit 2 would silently match
4451− // inside the already-modified region and corrupt the file instead
4452− // of producing a clear "Conflicting edit ranges" error.
4299+ // Edit 1's replacement introduces text that contains edit 2's
4300+ // old_text as a substring. Because edits resolve sequentially
4301+ // against the current buffer, edit 2 finds a unique match in
4302+ // the modified buffer and succeeds.
44534303 fs.insert_tree(
44544304 "/root",
44554305 json!({
@@ -4492,17 +4342,10 @@ mod tests { · 36 unmodified lines
44924342 }));
44934343 cx.run_until_parked();
44944344
4495− // Edit 1 targets "bbb\nccc" (lines 2-3) and replaces it with
4496− // text that preserves "ccc\nddd" as a contiguous substring in the
4497− // buffer — so edit 2's old_text will still match after edit 1 is
4498− // applied.
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− //
4345+ // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the
4346+ // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n".
4347+ // Edit 2's old_text "ccc\nddd" matches the first occurrence
4348+ // in the modified buffer and replaces it with "ZZZ".
45064349 // Edit 3 exists only to mark edit 2 as "complete" during streaming.
45074350 sender.send_partial(json!({
45084351 "display_description": "Overlapping edits",
@@ -4529,23 +4372,10 @@ mod tests { · 20 unmodified lines
45294372 }));
45304373
45314374 let result = task.await;
4532− // We expect a "Conflicting edit ranges" error. Currently the overlap
4533− // goes undetected during streaming and the file gets silently
4534− // corrupted, so this assertion will fail until we add early overlap
4535− // detection.
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− }
4375+ let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
4376+ panic!("expected success");
4377+ };
4378+ assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n");
45494379 }
45504380
45514381 #[gpui::test]
@@ -4585,7 +4415,7 @@ mod tests { · 33 unmodified lines
45854415 sender.send_partial(json!({
45864416 "display_description": "Create new file",
45874417 "path": "root/dir/new_file.txt",
4588− "mode": "create"
4418+ "mode": "write"
45894419 }));
45904420 cx.run_until_parked();
45914421
@@ -4593,7 +4423,7 @@ mod tests { · 1 unmodified line
45934423 sender.send_partial(json!({
45944424 "display_description": "Create new file",
45954425 "path": "root/dir/new_file.txt",
4596− "mode": "create",
4426+ "mode": "write",
45974427 "content": "line 1\n"
45984428 }));
45994429 cx.run_until_parked();
@@ -4611,7 +4441,7 @@ mod tests { · 11 unmodified lines
46114441 sender.send_partial(json!({
46124442 "display_description": "Create new file",
46134443 "path": "root/dir/new_file.txt",
4614− "mode": "create",
4444+ "mode": "write",
46154445 "content": "line 1\nline 2\n"
46164446 }));
46174447 cx.run_until_parked();
@@ -4621,7 +4451,7 @@ mod tests { · 3 unmodified lines
46214451 sender.send_partial(json!({
46224452 "display_description": "Create new file",
46234453 "path": "root/dir/new_file.txt",
4624− "mode": "create",
4454+ "mode": "write",
46254455 "content": "line 1\nline 2\nline 3\n"
46264456 }));
46274457 cx.run_until_parked();
@@ -4634,7 +4464,7 @@ mod tests { · 6 unmodified lines
46344464 sender.send_final(json!({
46354465 "display_description": "Create new file",
46364466 "path": "root/dir/new_file.txt",
4637− "mode": "create",
4467+ "mode": "write",
46384468 "content": "line 1\nline 2\nline 3\n"
46394469 }));
46404470
@@ -4688,7 +4518,7 @@ mod tests { · 47 unmodified lines
46884518 sender.send_partial(json!({
46894519 "display_description": "Overwrite file",
46904520 "path": "root/file.txt",
4691− "mode": "overwrite"
4521+ "mode": "write"
46924522 }));
46934523 cx.run_until_parked();
46944524
@@ -4706,7 +4536,7 @@ mod tests { · 11 unmodified lines
47064536 sender.send_partial(json!({
47074537 "display_description": "Overwrite file",
47084538 "path": "root/file.txt",
4709− "mode": "overwrite",
4539+ "mode": "write",
47104540 "content": "new line 1\n"
47114541 }));
47124542 cx.run_until_parked();
@@ -4720,7 +4550,7 @@ mod tests { · 7 unmodified lines
47204550 sender.send_final(json!({
47214551 "display_description": "Overwrite file",
47224552 "path": "root/file.txt",
4723− "mode": "overwrite",
4553+ "mode": "write",
47244554 "content": "new line 1\nnew line 2\n"
47254555 }));
47264556
@@ -4781,7 +4611,7 @@ mod tests { · 54 unmodified lines
47814611 sender.send_partial(json!({
47824612 "display_description": "Overwrite file",
47834613 "path": "root/file.txt",
4784− "mode": "overwrite"
4614+ "mode": "write"
47854615 }));
47864616 cx.run_until_parked();
47874617
@@ -4799,7 +4629,7 @@ mod tests { · 11 unmodified lines
47994629 sender.send_partial(json!({
48004630 "display_description": "Overwrite file",
48014631 "path": "root/file.txt",
4802− "mode": "overwrite",
4632+ "mode": "write",
48034633 "content": "new line 1\n"
48044634 }));
48054635 cx.run_until_parked();
@@ -4809,7 +4639,7 @@ mod tests { · 3 unmodified lines
48094639 sender.send_partial(json!({
48104640 "display_description": "Overwrite file",
48114641 "path": "root/file.txt",
4812− "mode": "overwrite",
4642+ "mode": "write",
48134643 "content": "new line 1\nnew line 2\n"
48144644 }));
48154645 cx.run_until_parked();
@@ -4822,7 +4652,7 @@ mod tests { · 6 unmodified lines
48224652 sender.send_final(json!({
48234653 "display_description": "Overwrite file",
48244654 "path": "root/file.txt",
4825− "mode": "overwrite",
4655+ "mode": "write",
48264656 "content": "new line 1\nnew line 2\nnew line 3\n"
48274657 }));
48284658
@@ -4837,6 +4667,89 @@ mod tests { · 8 unmodified lines
48374667 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
48384668 }
48394669
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+
48404753 fn init_test(cx: &mut TestAppContext) {
48414754 cx.update(|cx| {
48424755 let settings_store = SettingsStore::test(cx);
crates/agent/src/tools/tool_edit_parser.rs+941−0
@@ -0,0 +1,941 @@
1+use smallvec::SmallVec;
2+
3+use crate::{Edit, PartialEdit};
4+
5+/// Events emitted by `ToolEditParser` as tool call input streams in.
6+#[derive(Debug, PartialEq, Eq)]
7+pub 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)]
26+struct 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)]
49+pub struct ToolEditParser {
50+ edit_states: Vec<EditStreamState>,
51+ content_emitted_len: usize,
52+}
53+
54+impl 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.
247+fn 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)]
256+mod 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