From eb5e06b03be674844599fd9316ec7632318607ab Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 16:25:15 +0200 Subject: [PATCH 01/12] Add failing test --- crates/agent/src/tools/edit_file_tool.rs | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 69f7be4662abcb..4154f06b8af34b 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -3953,6 +3953,48 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_file_tool_new_text_before_old_text(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + #[gpui::test] async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { let file_content = indoc::indoc! {r#" From b9d48c058a163c58b1bd00148819be3e4ba95898 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 16:25:30 +0200 Subject: [PATCH 02/12] Fix --- .../tools/edit_file_tool/streaming_parser.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 6a44959a141c80..4ea433fbd66a65 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -108,6 +108,7 @@ impl StreamingParser { // Process new_text changes. if let Some(new_text) = &partial.new_text + && state.old_text_done && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); @@ -730,6 +731,37 @@ mod tests { ); } + #[test] + fn test_new_text_before_old_text_is_buffered() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: None, + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new".into(), + done: false, + }, + ] + ); + } + #[test] fn test_empty_old_text_with_new_text() { let mut parser = StreamingParser::default(); From f7cfff30481b0621bbd68708588b83ad28a8b1a7 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 16:33:59 +0200 Subject: [PATCH 03/12] Fix --- crates/agent/src/tools/edit_file_tool.rs | 7 +++++ .../tools/edit_file_tool/streaming_parser.rs | 29 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 4154f06b8af34b..22b23b73390331 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -3974,6 +3974,13 @@ mod tests { })); cx.run_until_parked(); + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_"}] + })); + cx.run_until_parked(); + sender.send_partial(json!({ "mode": "edit", "path": "root/file.txt", diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 4ea433fbd66a65..b953bbc6639ae9 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -31,6 +31,8 @@ pub enum WriteEvent { struct EditStreamState { old_text_emitted_len: usize, old_text_done: bool, + old_text_seen: bool, + new_text_seen_before_old_text: bool, new_text_emitted_len: usize, new_text_done: bool, } @@ -75,13 +77,17 @@ impl StreamingParser { } let state = &mut self.edit_states[index]; + if partial.new_text.is_some() && !state.old_text_seen && partial.old_text.is_none() { + state.new_text_seen_before_old_text = true; + } // Process old_text changes. if let Some(old_text) = &partial.old_text && !state.old_text_done { - if partial.new_text.is_some() { - // new_text appeared, so old_text is done — emit everything. + state.old_text_seen = true; + if partial.new_text.is_some() && !state.new_text_seen_before_old_text { + // new_text appeared after old_text, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; @@ -742,21 +748,34 @@ mod tests { assert!(events.is_empty()); let events = parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), + old_text: Some("ol".into()), new_text: Some("new".into()), }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ol".into(), + done: false, + }] + ); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), + }]); assert_eq!( events.as_slice(), &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "old".into(), + chunk: "d".into(), done: true, }, EditEvent::NewTextChunk { edit_index: 0, chunk: "new".into(), - done: false, + done: true, }, ] ); From dc5ae2d3bc3056a7e57cb79180ff5da37d00a4d2 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 16:45:01 +0200 Subject: [PATCH 04/12] Update streaming_parser.rs --- .../tools/edit_file_tool/streaming_parser.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index b953bbc6639ae9..169e035881eedd 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -781,6 +781,44 @@ mod tests { ); } + #[test] + fn test_first_observed_edit_with_new_text_and_partial_old_text_is_buffered() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ol".into()), + new_text: Some("new".into()), + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ol".into(), + done: false, + }] + ); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), + }]); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "d".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new".into(), + done: true, + }, + ] + ); + } + #[test] fn test_empty_old_text_with_new_text() { let mut parser = StreamingParser::default(); From 42e8139b9d93768d1ecd3a9f81171972800a35fc Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 16:45:32 +0200 Subject: [PATCH 05/12] Update streaming_parser.rs --- crates/agent/src/tools/edit_file_tool/streaming_parser.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 169e035881eedd..a622eff93b2c28 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -114,7 +114,6 @@ impl StreamingParser { // Process new_text changes. if let Some(new_text) = &partial.new_text - && state.old_text_done && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); From 6b3f0afbda3e91d69bf187fd13a79c0c3c8c2b95 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 17:19:24 +0200 Subject: [PATCH 06/12] WIP --- crates/agent/src/tools/edit_file_tool.rs | 4 +- .../tools/edit_file_tool/streaming_parser.rs | 71 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 22b23b73390331..f125824b984e15 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -3977,14 +3977,14 @@ mod tests { sender.send_partial(json!({ "mode": "edit", "path": "root/file.txt", - "edits": [{"new_text": "new_content", "old_text": "old_"}] + "edits": [{"new_text": "new_content", "old_text": ""}] })); cx.run_until_parked(); sender.send_partial(json!({ "mode": "edit", "path": "root/file.txt", - "edits": [{"new_text": "new_content", "old_text": "old_content"}] + "edits": [{"new_text": "new_content", "old_text": "old"}] })); cx.run_until_parked(); diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index a622eff93b2c28..54617df0830667 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -31,8 +31,6 @@ pub enum WriteEvent { struct EditStreamState { old_text_emitted_len: usize, old_text_done: bool, - old_text_seen: bool, - new_text_seen_before_old_text: bool, new_text_emitted_len: usize, new_text_done: bool, } @@ -77,16 +75,12 @@ impl StreamingParser { } let state = &mut self.edit_states[index]; - if partial.new_text.is_some() && !state.old_text_seen && partial.old_text.is_none() { - state.new_text_seen_before_old_text = true; - } // Process old_text changes. if let Some(old_text) = &partial.old_text && !state.old_text_done { - state.old_text_seen = true; - if partial.new_text.is_some() && !state.new_text_seen_before_old_text { + if partial.new_text.is_some() { // new_text appeared after old_text, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); let chunk = normalize_done_chunk(old_text[start..].to_string()); @@ -114,6 +108,7 @@ impl StreamingParser { // Process new_text changes. if let Some(new_text) = &partial.new_text + && state.old_text_done && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); @@ -737,7 +732,7 @@ mod tests { } #[test] - fn test_new_text_before_old_text_is_buffered() { + fn test_new_text_before_empty_old_text_is_buffered() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { @@ -747,17 +742,10 @@ mod tests { assert!(events.is_empty()); let events = parser.push_edits(&[PartialEdit { - old_text: Some("ol".into()), + old_text: Some("".into()), new_text: Some("new".into()), }]); - assert_eq!( - events.as_slice(), - &[EditEvent::OldTextChunk { - edit_index: 0, - chunk: "ol".into(), - done: false, - }] - ); + assert!(events.is_empty()); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -768,7 +756,7 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "d".into(), + chunk: "old".into(), done: true, }, EditEvent::NewTextChunk { @@ -781,52 +769,65 @@ mod tests { } #[test] - fn test_first_observed_edit_with_new_text_and_partial_old_text_is_buffered() { + fn test_new_text_before_complete_old_text_streams_when_old_text_arrives() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { - old_text: Some("ol".into()), + old_text: None, new_text: Some("new".into()), }]); - assert_eq!( - events.as_slice(), - &[EditEvent::OldTextChunk { - edit_index: 0, - chunk: "ol".into(), - done: false, - }] - ); + assert!(events.is_empty()); - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "new".into(), + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), }]); assert_eq!( events.as_slice(), &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "d".into(), + chunk: "old".into(), done: true, }, EditEvent::NewTextChunk { edit_index: 0, chunk: "new".into(), - done: true, + done: false, }, ] ); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::NewTextChunk { + edit_index: 0, + chunk: "".into(), + done: true, + }] + ); } #[test] fn test_empty_old_text_with_new_text() { let mut parser = StreamingParser::default(); - // old_text is empty, new_text appears immediately + // old_text is empty, new_text appears immediately. Empty old_text with + // no prior emissions is not finalized during streaming. let events = parser.push_edits(&[PartialEdit { old_text: Some("".into()), new_text: Some("inserted".into()), }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "".into(), + new_text: "inserted".into(), + }]); assert_eq!( events.as_slice(), &[ @@ -838,7 +839,7 @@ mod tests { EditEvent::NewTextChunk { edit_index: 0, chunk: "inserted".into(), - done: false, + done: true, }, ] ); From d95107aaf4861f1b235c1fe159904be02b8ad2e5 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 17:33:46 +0200 Subject: [PATCH 07/12] More tests --- crates/agent/src/tools/edit_file_tool.rs | 44 +++++++++++ .../tools/edit_file_tool/streaming_parser.rs | 79 +++++++++++++++---- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index f125824b984e15..c9c2a99820ce45 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -3953,6 +3953,50 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_file_tool_new_and_old_text_appear_together( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + #[gpui::test] async fn test_streaming_edit_file_tool_new_text_before_old_text(cx: &mut TestAppContext) { let (tool, _project, _action_log, _fs, _thread) = diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 54617df0830667..0a5381671635ad 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -732,20 +732,34 @@ mod tests { } #[test] - fn test_new_text_before_empty_old_text_is_buffered() { + fn test_new_text_and_old_text_appearing_together_does_not_finalize_old_text() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { - old_text: None, + old_text: Some("ol".into()), new_text: Some("new".into()), }]); - assert!(events.is_empty()); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ol".into(), + done: false, + }] + ); let events = parser.push_edits(&[PartialEdit { - old_text: Some("".into()), + old_text: Some("old".into()), new_text: Some("new".into()), }]); - assert!(events.is_empty()); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "d".into(), + done: false, + }] + ); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -756,7 +770,7 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "old".into(), + chunk: "".into(), done: true, }, EditEvent::NewTextChunk { @@ -769,7 +783,7 @@ mod tests { } #[test] - fn test_new_text_before_complete_old_text_streams_when_old_text_arrives() { + fn test_new_text_before_empty_old_text_is_buffered() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { @@ -779,9 +793,15 @@ mod tests { assert!(events.is_empty()); let events = parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), + old_text: Some("".into()), new_text: Some("new".into()), }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), + }]); assert_eq!( events.as_slice(), &[ @@ -793,10 +813,34 @@ mod tests { EditEvent::NewTextChunk { edit_index: 0, chunk: "new".into(), - done: false, + done: true, }, ] ); + } + + #[test] + fn test_new_text_before_old_text_waits_for_finalize_to_stream_new_text() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: None, + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: false, + }] + ); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -804,11 +848,18 @@ mod tests { }]); assert_eq!( events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: "".into(), - done: true, - }] + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new".into(), + done: true, + }, + ] ); } From 9e43910b38509b1aaf85b69515588c4dbbdd3aa3 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 18:23:41 +0200 Subject: [PATCH 08/12] Fix it --- .../tools/edit_file_tool/streaming_parser.rs | 158 +++++++++++++----- 1 file changed, 120 insertions(+), 38 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 0a5381671635ad..58edfcb2dec24c 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -33,6 +33,7 @@ struct EditStreamState { old_text_done: bool, new_text_emitted_len: usize, new_text_done: bool, + hold_until_complete: bool, } /// Converts incrementally-growing tool call JSON into a stream of chunk events. @@ -65,17 +66,37 @@ impl StreamingParser { pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[EditEvent; 4]> { let mut events = SmallVec::new(); + let mut skip_events = false; for (index, partial) in edits.iter().enumerate() { if index >= self.edit_states.len() { // A new edit appeared — finalize the previous one if there was one. - if let Some(previous) = self.finalize_previous_edit(index) { + if !skip_events && let Some(previous) = self.finalize_previous_edit(index) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); } + if skip_events { + continue; + } + let state = &mut self.edit_states[index]; + // Detect if model streams in new_text before old_text is done. + // If so, we do not emit any events until old_text is done. + if state.old_text_emitted_len == 0 + && state.new_text_emitted_len == 0 + && !state.old_text_done + && partial.new_text.is_some() + { + state.hold_until_complete = true; + } + + if state.hold_until_complete { + skip_events = true; + continue; + } + // Process old_text changes. if let Some(old_text) = &partial.old_text && !state.old_text_done @@ -166,6 +187,25 @@ impl StreamingParser { let state = &mut self.edit_states[index]; + if state.hold_until_complete { + state.old_text_done = true; + state.old_text_emitted_len = edit.old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = edit.new_text.len(); + state.hold_until_complete = false; + events.push(EditEvent::OldTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.old_text.clone()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.new_text.clone()), + done: true, + }); + continue; + } + if !state.old_text_done { let start = state.old_text_emitted_len.min(edit.old_text.len()); let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); @@ -280,6 +320,43 @@ fn normalize_done_chunk(mut chunk: String) -> String { mod tests { use super::*; + #[test] + fn test_first_edit_with_new_text_in_first_chunk_is_held_until_finalize() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old text".into()), + new_text: Some("new text".into()), + }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "old text".into(), + new_text: "new text".into(), + }]); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old text".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new text".into(), + done: true, + }, + ] + ); + } + #[test] fn test_single_edit_streamed_incrementally() { let mut parser = StreamingParser::default(); @@ -394,6 +471,12 @@ mod tests { old_text: Some("before\n".into()), new_text: Some("after\n".into()), }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "before\n".into(), + new_text: "after\n".into(), + }]); assert_eq!( events.as_slice(), &[ @@ -405,23 +488,10 @@ mod tests { EditEvent::NewTextChunk { edit_index: 0, chunk: "after".into(), - done: false, + done: true, }, ] ); - - let events = parser.finalize_edits(&[Edit { - old_text: "before\n".into(), - new_text: "after\n".into(), - }]); - assert_eq!( - events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: "".into(), - done: true, - }] - ); } #[test] @@ -934,22 +1004,7 @@ mod tests { }, ]); - // Should finalize edit 1 (index=1) and start edit 2 (index=2) - assert_eq!( - events.as_slice(), - &[ - EditEvent::NewTextChunk { - edit_index: 1, - chunk: "".into(), - done: true, - }, - EditEvent::OldTextChunk { - edit_index: 2, - chunk: "c".into(), - done: false, - }, - ] - ); + assert!(events.is_empty()); // Finalize let events = parser.finalize_edits(&[ @@ -969,9 +1024,29 @@ mod tests { assert_eq!( events.as_slice(), &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "a".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "A".into(), + done: true, + }, + EditEvent::OldTextChunk { + edit_index: 1, + chunk: "b".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 1, + chunk: "B".into(), + done: true, + }, EditEvent::OldTextChunk { edit_index: 2, - chunk: "".into(), + chunk: "c".into(), done: true, }, EditEvent::NewTextChunk { @@ -1029,11 +1104,18 @@ mod tests { }]); assert_eq!( events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: " new text".into(), - done: true, - }] + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "partial new text".into(), + done: true, + }, + ] ); } @@ -1045,7 +1127,7 @@ mod tests { old_text: Some("stable".into()), new_text: Some("also stable".into()), }]); - assert_eq!(events.len(), 2); // old done + new chunk + assert!(events.is_empty()); // Push the exact same data again let events = parser.push_edits(&[PartialEdit { From bddc69d45defe08b226650c5621a1efc0af63da2 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 18:27:26 +0200 Subject: [PATCH 09/12] Fix test --- .../tools/edit_file_tool/streaming_parser.rs | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 58edfcb2dec24c..dd6ab7831fa257 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -809,27 +809,13 @@ mod tests { old_text: Some("ol".into()), new_text: Some("new".into()), }]); - assert_eq!( - events.as_slice(), - &[EditEvent::OldTextChunk { - edit_index: 0, - chunk: "ol".into(), - done: false, - }] - ); + assert!(events.is_empty()); let events = parser.push_edits(&[PartialEdit { old_text: Some("old".into()), new_text: Some("new".into()), }]); - assert_eq!( - events.as_slice(), - &[EditEvent::OldTextChunk { - edit_index: 0, - chunk: "d".into(), - done: false, - }] - ); + assert!(events.is_empty()); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -840,7 +826,7 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "".into(), + chunk: "old".into(), done: true, }, EditEvent::NewTextChunk { From 2d370618f42a6ca353865e6c6d8c9c4645c1a5e8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 19:46:26 +0200 Subject: [PATCH 10/12] Tests --- crates/agent/src/tools/edit_file_tool.rs | 26 +++++ .../tools/edit_file_tool/streaming_parser.rs | 108 +++++++++++------- 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index c9c2a99820ce45..5f786a85a2c165 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1853,6 +1853,15 @@ mod tests { cx.run_until_parked(); // Edit 2 appears — edit 1 is now complete and should be applied + sender.send_partial(json!({ + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc"} + ] + })); + cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", "mode": "edit", @@ -1875,6 +1884,16 @@ mod tests { assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n")); // Edit 3 appears — edit 2 is now complete and should be applied + sender.send_partial(json!({ + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee"} + ] + })); + cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", "mode": "edit", @@ -2019,6 +2038,13 @@ mod tests { })); cx.run_until_parked(); + sender.send_partial(json!({ + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello world"}] + })); + cx.run_until_parked(); + sender.send_partial(json!({ "path": "root/file.txt", "mode": "edit", diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index dd6ab7831fa257..8625eb2db0e0af 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -66,20 +66,23 @@ impl StreamingParser { pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[EditEvent; 4]> { let mut events = SmallVec::new(); - let mut skip_events = false; for (index, partial) in edits.iter().enumerate() { if index >= self.edit_states.len() { // A new edit appeared — finalize the previous one if there was one. - if !skip_events && let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.old_text.as_deref()), + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.new_text.as_deref()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); } - if skip_events { - continue; - } - let state = &mut self.edit_states[index]; // Detect if model streams in new_text before old_text is done. @@ -93,7 +96,6 @@ impl StreamingParser { } if state.hold_until_complete { - skip_events = true; continue; } @@ -179,7 +181,15 @@ impl StreamingParser { for (index, edit) in edits.iter().enumerate() { if index >= self.edit_states.len() { // This edit was never seen in partials — emit it fully. - if let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.old_text.as_str()), + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.new_text.as_str()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); @@ -250,7 +260,12 @@ impl StreamingParser { /// When a new edit appears at `index`, finalize the edit at `index - 1` /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized. - fn finalize_previous_edit(&mut self, new_index: usize) -> Option> { + fn finalize_previous_edit( + &mut self, + new_index: usize, + old_text: Option<&str>, + new_text: Option<&str>, + ) -> Option> { if new_index == 0 || self.edit_states.is_empty() { return None; } @@ -263,6 +278,27 @@ impl StreamingParser { let state = &mut self.edit_states[previous_index]; let mut events = SmallVec::new(); + if state.hold_until_complete { + let old_text = old_text.unwrap_or_default(); + let new_text = new_text.unwrap_or_default(); + state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.hold_until_complete = false; + events.push(EditEvent::OldTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(old_text.to_string()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(new_text.to_string()), + done: true, + }); + return Some(events); + } + // If old_text was never finalized, finalize it now with an empty done chunk. if !state.old_text_done { state.old_text_done = true; @@ -889,14 +925,7 @@ mod tests { old_text: Some("old".into()), new_text: Some("new".into()), }]); - assert_eq!( - events.as_slice(), - &[EditEvent::OldTextChunk { - edit_index: 0, - chunk: "old".into(), - done: false, - }] - ); + assert!(events.is_empty()); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -907,7 +936,7 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "".into(), + chunk: "old".into(), done: true, }, EditEvent::NewTextChunk { @@ -990,7 +1019,26 @@ mod tests { }, ]); - assert!(events.is_empty()); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 1, + chunk: "b".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 1, + chunk: "B".into(), + done: true, + }, + EditEvent::OldTextChunk { + edit_index: 2, + chunk: "c".into(), + done: false, + }, + ] + ); // Finalize let events = parser.finalize_edits(&[ @@ -1010,29 +1058,9 @@ mod tests { assert_eq!( events.as_slice(), &[ - EditEvent::OldTextChunk { - edit_index: 0, - chunk: "a".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 0, - chunk: "A".into(), - done: true, - }, - EditEvent::OldTextChunk { - edit_index: 1, - chunk: "b".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 1, - chunk: "B".into(), - done: true, - }, EditEvent::OldTextChunk { edit_index: 2, - chunk: "c".into(), + chunk: "".into(), done: true, }, EditEvent::NewTextChunk { From 1d26249af376f1c2ca72d2feb036603dce772e66 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 19:57:00 +0200 Subject: [PATCH 11/12] Update streaming_parser.rs --- .../tools/edit_file_tool/streaming_parser.rs | 141 +----------------- 1 file changed, 2 insertions(+), 139 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs index 8625eb2db0e0af..03ca368930c073 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_file_tool/streaming_parser.rs @@ -85,8 +85,8 @@ impl StreamingParser { let state = &mut self.edit_states[index]; - // Detect if model streams in new_text before old_text is done. - // If so, we do not emit any events until old_text is done. + // If new_text appears before any old_text chunk has been emitted, hold the edit + // until it is complete so old_text and new_text can be emitted atomically. if state.old_text_emitted_len == 0 && state.new_text_emitted_len == 0 && !state.old_text_done @@ -837,80 +837,6 @@ mod tests { ); } - #[test] - fn test_new_text_and_old_text_appearing_together_does_not_finalize_old_text() { - let mut parser = StreamingParser::default(); - - let events = parser.push_edits(&[PartialEdit { - old_text: Some("ol".into()), - new_text: Some("new".into()), - }]); - assert!(events.is_empty()); - - let events = parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), - new_text: Some("new".into()), - }]); - assert!(events.is_empty()); - - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "new".into(), - }]); - assert_eq!( - events.as_slice(), - &[ - EditEvent::OldTextChunk { - edit_index: 0, - chunk: "old".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 0, - chunk: "new".into(), - done: true, - }, - ] - ); - } - - #[test] - fn test_new_text_before_empty_old_text_is_buffered() { - let mut parser = StreamingParser::default(); - - let events = parser.push_edits(&[PartialEdit { - old_text: None, - new_text: Some("new".into()), - }]); - assert!(events.is_empty()); - - let events = parser.push_edits(&[PartialEdit { - old_text: Some("".into()), - new_text: Some("new".into()), - }]); - assert!(events.is_empty()); - - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "new".into(), - }]); - assert_eq!( - events.as_slice(), - &[ - EditEvent::OldTextChunk { - edit_index: 0, - chunk: "old".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 0, - chunk: "new".into(), - done: true, - }, - ] - ); - } - #[test] fn test_new_text_before_old_text_waits_for_finalize_to_stream_new_text() { let mut parser = StreamingParser::default(); @@ -948,39 +874,6 @@ mod tests { ); } - #[test] - fn test_empty_old_text_with_new_text() { - let mut parser = StreamingParser::default(); - - // old_text is empty, new_text appears immediately. Empty old_text with - // no prior emissions is not finalized during streaming. - let events = parser.push_edits(&[PartialEdit { - old_text: Some("".into()), - new_text: Some("inserted".into()), - }]); - assert!(events.is_empty()); - - let events = parser.finalize_edits(&[Edit { - old_text: "".into(), - new_text: "inserted".into(), - }]); - assert_eq!( - events.as_slice(), - &[ - EditEvent::OldTextChunk { - edit_index: 0, - chunk: "".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 0, - chunk: "inserted".into(), - done: true, - }, - ] - ); - } - #[test] fn test_three_edits_streamed() { let mut parser = StreamingParser::default(); @@ -1103,36 +996,6 @@ mod tests { ); } - #[test] - fn test_finalize_with_partially_seen_new_text() { - let mut parser = StreamingParser::default(); - - parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), - new_text: Some("partial".into()), - }]); - - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "partial new text".into(), - }]); - assert_eq!( - events.as_slice(), - &[ - EditEvent::OldTextChunk { - edit_index: 0, - chunk: "old".into(), - done: true, - }, - EditEvent::NewTextChunk { - edit_index: 0, - chunk: "partial new text".into(), - done: true, - }, - ] - ); - } - #[test] fn test_repeated_pushes_with_no_change() { let mut parser = StreamingParser::default(); From 0fcb32fd3a1b51ee879ce6664cc54a643f0497e3 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 20:26:29 +0200 Subject: [PATCH 12/12] Optimize new_text before old_text --- .../tools/edit_session/streaming_parser.rs | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index 113deeab3c2b16..3961edf564ccfc 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -34,6 +34,7 @@ struct EditStreamState { new_text_emitted_len: usize, new_text_done: bool, hold_until_complete: bool, + buffer_new_text_until_old_text_done: bool, } /// Converts incrementally-growing tool call JSON into a stream of chunk events. @@ -85,14 +86,21 @@ impl StreamingParser { let state = &mut self.edit_states[index]; - // If new_text appears before any old_text chunk has been emitted, hold the edit - // until it is complete so old_text and new_text can be emitted atomically. if state.old_text_emitted_len == 0 && state.new_text_emitted_len == 0 && !state.old_text_done && partial.new_text.is_some() + && !state.buffer_new_text_until_old_text_done { - state.hold_until_complete = true; + if partial + .old_text + .as_ref() + .is_some_and(|old_text| !old_text.is_empty()) + { + state.hold_until_complete = true; + } else { + state.buffer_new_text_until_old_text_done = true; + } } if state.hold_until_complete { @@ -103,7 +111,7 @@ impl StreamingParser { if let Some(old_text) = &partial.old_text && !state.old_text_done { - if partial.new_text.is_some() { + if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { // new_text appeared after old_text, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); let chunk = normalize_done_chunk(old_text[start..].to_string()); @@ -203,6 +211,7 @@ impl StreamingParser { state.new_text_done = true; state.new_text_emitted_len = edit.new_text.len(); state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::OldTextChunk { edit_index: index, chunk: normalize_done_chunk(edit.old_text.clone()), @@ -286,6 +295,7 @@ impl StreamingParser { state.new_text_done = true; state.new_text_emitted_len = new_text.len(); state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::OldTextChunk { edit_index: previous_index, chunk: normalize_done_chunk(old_text.to_string()), @@ -299,22 +309,27 @@ impl StreamingParser { return Some(events); } - // If old_text was never finalized, finalize it now with an empty done chunk. if !state.old_text_done { + let old_text = old_text.unwrap_or_default(); + let start = state.old_text_emitted_len.min(old_text.len()); state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); events.push(EditEvent::OldTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(old_text[start..].to_string()), done: true, }); } - // Emit a done event for new_text if not already finalized. if !state.new_text_done { + let new_text = new_text.unwrap_or_default(); + let start = state.new_text_emitted_len.min(new_text.len()); state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::NewTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(new_text[start..].to_string()), done: true, }); } @@ -838,7 +853,7 @@ mod tests { } #[test] - fn test_new_text_before_old_text_waits_for_finalize_to_stream_new_text() { + fn test_new_text_before_old_text_buffers_new_text_but_streams_old_text() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { @@ -851,7 +866,14 @@ mod tests { old_text: Some("old".into()), new_text: Some("new".into()), }]); - assert!(events.is_empty()); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: false, + }] + ); let events = parser.finalize_edits(&[Edit { old_text: "old".into(), @@ -862,7 +884,7 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "old".into(), + chunk: "".into(), done: true, }, EditEvent::NewTextChunk { @@ -1002,21 +1024,28 @@ mod tests { let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); - assert!(events.is_empty()); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "stable".into(), + done: false, + }] + ); // Push the exact same data again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); // And again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); }