agent: Refactor edit file tool state handling (#55663)

Ports some changes introduced in #51165 out to make merge conflicts
easier to handle.

Splits the `Pipeline` into two separate types for mode edit/write so we
don't need to maintain that invariant inside the pipeline/in the parser

Also moves the parser to be a submodule of `edit_file_tool`

Release Notes:

- N/A
This commit is contained in:
Bennet Bo Fenner 2026-05-04 17:45:06 +02:00 committed by GitHub
parent 11371e6e1b
commit 8f2ab516d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 393 additions and 346 deletions

View file

@ -18,7 +18,6 @@ mod restore_file_from_disk_tool;
mod save_file_tool;
mod spawn_agent_tool;
mod terminal_tool;
mod tool_edit_parser;
mod tool_permissions;
mod update_plan_tool;
mod web_search_tool;

View file

@ -1,13 +1,14 @@
mod reindent;
mod streaming_fuzzy_matcher;
mod streaming_parser;
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
use super::tool_edit_parser::{ToolEditEvent, ToolEditParser};
use crate::ToolInputPayload;
use crate::tools::edit_file_tool::{
reindent::{Reindenter, compute_indent_delta},
streaming_fuzzy_matcher::StreamingFuzzyMatcher,
streaming_parser::{EditEvent, StreamingParser, WriteEvent},
};
use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput};
use acp_thread::Diff;
@ -598,16 +599,23 @@ pub struct EditSession {
buffer: Entity<Buffer>,
old_text: Arc<String>,
diff: Entity<Diff>,
mode: EditFileMode,
parser: ToolEditParser,
pipeline: EditPipeline,
file_changed_since_last_read: bool,
parser: StreamingParser,
pipeline: Pipeline,
_finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
}
enum Pipeline {
Write(WritePipeline),
Edit(EditPipeline),
}
struct WritePipeline {
content_written: bool,
}
struct EditPipeline {
current_edit: Option<EditPipelineEntry>,
content_written: bool,
file_changed_since_last_read: bool,
}
enum EditPipelineEntry {
@ -622,14 +630,51 @@ enum EditPipelineEntry {
},
}
impl EditPipeline {
fn new() -> Self {
Self {
current_edit: None,
content_written: false,
impl Pipeline {
fn new(mode: EditFileMode, file_changed_since_last_read: bool) -> Self {
match mode {
EditFileMode::Write => Self::Write(WritePipeline {
content_written: false,
}),
EditFileMode::Edit => Self::Edit(EditPipeline {
current_edit: None,
file_changed_since_last_read,
}),
}
}
}
impl WritePipeline {
fn process_event(
&mut self,
event: &WriteEvent,
buffer: &Entity<Buffer>,
tool: &EditFileTool,
cx: &mut AsyncApp,
) {
let WriteEvent::ContentChunk { chunk } = event;
let (buffer_id, buffer_len) =
buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len()));
let edit_range = if self.content_written {
buffer_len..buffer_len
} else {
0..buffer_len
};
agent_edit_buffer(buffer, [(edit_range, chunk.as_str())], &tool.action_log, cx);
cx.update(|cx| {
tool.set_agent_location(
buffer.downgrade(),
text::Anchor::max_for_buffer(buffer_id),
cx,
);
});
self.content_written = true;
}
}
impl EditPipeline {
fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
if self.current_edit.is_none() {
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
@ -638,6 +683,199 @@ impl EditPipeline {
});
}
}
fn process_event(
&mut self,
event: &EditEvent,
buffer: &Entity<Buffer>,
diff: &Entity<Diff>,
abs_path: &PathBuf,
tool: &EditFileTool,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), String> {
match event {
EditEvent::OldTextChunk {
chunk, done: false, ..
} => {
log::debug!("old_text_chunk: done=false, chunk='{}'", chunk);
self.ensure_resolving_old_text(buffer, cx);
if let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
&mut self.current_edit
&& !chunk.is_empty()
{
if let Some(match_range) = matcher.push(chunk, None) {
let anchor_range = buffer.read_with(cx, |buffer, _cx| {
buffer.anchor_range_outside(match_range.clone())
});
diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
cx.update(|cx| {
let position = buffer.read(cx).anchor_before(match_range.end);
tool.set_agent_location(buffer.downgrade(), position, cx);
});
}
}
}
EditEvent::OldTextChunk {
edit_index,
chunk,
done: true,
} => {
log::debug!("old_text_chunk: done=true, chunk='{}'", chunk);
self.ensure_resolving_old_text(buffer, cx);
let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit
else {
return Ok(());
};
if !chunk.is_empty() {
matcher.push(chunk, None);
}
let range = extract_match(
matcher.finish(),
buffer,
edit_index,
self.file_changed_since_last_read,
cx,
)?;
let anchor_range =
buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone()));
diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
let line = snapshot.offset_to_point(range.start).row;
event_stream.update_fields(
ToolCallUpdateFields::new()
.locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
);
let buffer_indent = snapshot.line_indent_for_row(line);
let query_indent = text::LineIndent::from_iter(
matcher
.query_lines()
.first()
.map(|s| s.as_str())
.unwrap_or("")
.chars(),
);
let indent_delta = compute_indent_delta(buffer_indent, query_indent);
let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::<String>();
log::debug!(
"edit[{}] old_text matched at {}..{}: {:?}",
edit_index,
range.start,
range.end,
old_text_in_buffer,
);
let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
self.current_edit = Some(EditPipelineEntry::StreamingNewText {
streaming_diff: StreamingDiff::new(old_text_in_buffer),
edit_cursor: range.start,
reindenter: Reindenter::new(indent_delta),
original_snapshot: text_snapshot,
});
cx.update(|cx| {
let position = buffer.read(cx).anchor_before(range.end);
tool.set_agent_location(buffer.downgrade(), position, cx);
});
}
EditEvent::NewTextChunk {
chunk, done: false, ..
} => {
log::debug!("new_text_chunk: done=false, chunk='{}'", chunk);
let Some(EditPipelineEntry::StreamingNewText {
streaming_diff,
edit_cursor,
reindenter,
original_snapshot,
..
}) = &mut self.current_edit
else {
return Ok(());
};
let reindented = reindenter.push(chunk);
if reindented.is_empty() {
return Ok(());
}
let char_ops = streaming_diff.push_new(&reindented);
apply_char_operations(
&char_ops,
buffer,
original_snapshot,
edit_cursor,
&tool.action_log,
cx,
);
let position = original_snapshot.anchor_before(*edit_cursor);
cx.update(|cx| {
tool.set_agent_location(buffer.downgrade(), position, cx);
});
}
EditEvent::NewTextChunk {
chunk, done: true, ..
} => {
log::debug!("new_text_chunk: done=true, chunk='{}'", chunk);
let Some(EditPipelineEntry::StreamingNewText {
mut streaming_diff,
mut edit_cursor,
mut reindenter,
original_snapshot,
}) = self.current_edit.take()
else {
return Ok(());
};
// Flush any remaining reindent buffer + final chunk.
let mut final_text = reindenter.push(chunk);
final_text.push_str(&reindenter.finish());
log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
if !final_text.is_empty() {
let char_ops = streaming_diff.push_new(&final_text);
apply_char_operations(
&char_ops,
buffer,
&original_snapshot,
&mut edit_cursor,
&tool.action_log,
cx,
);
}
let remaining_ops = streaming_diff.finish();
apply_char_operations(
&remaining_ops,
buffer,
&original_snapshot,
&mut edit_cursor,
&tool.action_log,
cx,
);
let position = original_snapshot.anchor_before(edit_cursor);
cx.update(|cx| {
tool.set_agent_location(buffer.downgrade(), position, cx);
});
}
}
Ok(())
}
}
impl EditSession {
@ -704,10 +942,8 @@ impl EditSession {
buffer,
old_text,
diff,
mode,
parser: ToolEditParser::default(),
pipeline: EditPipeline::new(),
file_changed_since_last_read,
parser: StreamingParser::default(),
pipeline: Pipeline::new(mode, file_changed_since_last_read),
_finalize_diff_guard: finalize_diff_guard,
})
}
@ -719,21 +955,39 @@ impl EditSession {
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), String> {
match input.mode {
EditFileMode::Write => {
let Self {
abs_path,
buffer,
diff,
parser,
pipeline,
..
} = self;
match pipeline {
Pipeline::Write(write) => {
let content = input
.content
.ok_or_else(|| "'content' field is required for write mode".to_string())?;
let events = self.parser.finalize_content(&content);
self.process_events(&events, tool, event_stream, cx)?;
for event in &parser.finalize_content(&content) {
write.process_event(event, buffer, tool, cx);
}
}
EditFileMode::Edit => {
Pipeline::Edit(edit_pipeline) => {
let edits = input
.edits
.ok_or_else(|| "'edits' field is required for edit mode".to_string())?;
let events = self.parser.finalize_edits(&edits);
self.process_events(&events, tool, event_stream, cx)?;
for event in &parser.finalize_edits(&edits) {
edit_pipeline.process_event(
event,
buffer,
diff,
abs_path,
tool,
event_stream,
cx,
)?;
}
if log::log_enabled!(log::Level::Debug) {
log::debug!("Got edits:");
@ -773,247 +1027,36 @@ impl EditSession {
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), String> {
match &self.mode {
EditFileMode::Write => {
let Self {
abs_path,
buffer,
diff,
parser,
pipeline,
..
} = self;
match pipeline {
Pipeline::Write(write) => {
if let Some(content) = &partial.content {
let events = self.parser.push_content(content);
self.process_events(&events, tool, event_stream, cx)?;
for event in &parser.push_content(content) {
write.process_event(event, buffer, tool, cx);
}
}
}
EditFileMode::Edit => {
Pipeline::Edit(edit_pipeline) => {
if let Some(edits) = partial.edits {
let events = self.parser.push_edits(&edits);
self.process_events(&events, tool, event_stream, cx)?;
}
}
}
Ok(())
}
fn process_events(
&mut self,
events: &[ToolEditEvent],
tool: &EditFileTool,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), String> {
for event in events {
match event {
ToolEditEvent::ContentChunk { chunk } => {
let (buffer_id, buffer_len) = self
.buffer
.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len()));
let edit_range = if self.pipeline.content_written {
buffer_len..buffer_len
} else {
0..buffer_len
};
agent_edit_buffer(
&self.buffer,
[(edit_range, chunk.as_str())],
&tool.action_log,
cx,
);
cx.update(|cx| {
tool.set_agent_location(
self.buffer.downgrade(),
text::Anchor::max_for_buffer(buffer_id),
for event in &parser.push_edits(&edits) {
edit_pipeline.process_event(
event,
buffer,
diff,
abs_path,
tool,
event_stream,
cx,
);
});
self.pipeline.content_written = true;
}
ToolEditEvent::OldTextChunk {
chunk, done: false, ..
} => {
log::debug!("old_text_chunk: done=false, chunk='{}'", chunk);
self.pipeline.ensure_resolving_old_text(&self.buffer, cx);
if let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
&mut self.pipeline.current_edit
&& !chunk.is_empty()
{
if let Some(match_range) = matcher.push(chunk, None) {
let anchor_range = self.buffer.read_with(cx, |buffer, _cx| {
buffer.anchor_range_outside(match_range.clone())
});
self.diff
.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
cx.update(|cx| {
let position = self.buffer.read(cx).anchor_before(match_range.end);
tool.set_agent_location(self.buffer.downgrade(), position, cx);
});
}
)?;
}
}
ToolEditEvent::OldTextChunk {
edit_index,
chunk,
done: true,
} => {
log::debug!("old_text_chunk: done=true, chunk='{}'", chunk);
self.pipeline.ensure_resolving_old_text(&self.buffer, cx);
let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
&mut self.pipeline.current_edit
else {
continue;
};
if !chunk.is_empty() {
matcher.push(chunk, None);
}
let range = extract_match(
matcher.finish(),
&self.buffer,
edit_index,
self.file_changed_since_last_read,
cx,
)?;
let anchor_range = self
.buffer
.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone()));
self.diff
.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
let snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
let line = snapshot.offset_to_point(range.start).row;
event_stream.update_fields(
ToolCallUpdateFields::new().locations(vec![
ToolCallLocation::new(&self.abs_path).line(Some(line)),
]),
);
let buffer_indent = snapshot.line_indent_for_row(line);
let query_indent = text::LineIndent::from_iter(
matcher
.query_lines()
.first()
.map(|s| s.as_str())
.unwrap_or("")
.chars(),
);
let indent_delta = compute_indent_delta(buffer_indent, query_indent);
let old_text_in_buffer =
snapshot.text_for_range(range.clone()).collect::<String>();
log::debug!(
"edit[{}] old_text matched at {}..{}: {:?}",
edit_index,
range.start,
range.end,
old_text_in_buffer,
);
let text_snapshot = self
.buffer
.read_with(cx, |buffer, _cx| buffer.text_snapshot());
self.pipeline.current_edit = Some(EditPipelineEntry::StreamingNewText {
streaming_diff: StreamingDiff::new(old_text_in_buffer),
edit_cursor: range.start,
reindenter: Reindenter::new(indent_delta),
original_snapshot: text_snapshot,
});
cx.update(|cx| {
let position = self.buffer.read(cx).anchor_before(range.end);
tool.set_agent_location(self.buffer.downgrade(), position, cx);
});
}
ToolEditEvent::NewTextChunk {
chunk, done: false, ..
} => {
log::debug!("new_text_chunk: done=false, chunk='{}'", chunk);
let Some(EditPipelineEntry::StreamingNewText {
streaming_diff,
edit_cursor,
reindenter,
original_snapshot,
..
}) = &mut self.pipeline.current_edit
else {
continue;
};
let reindented = reindenter.push(chunk);
if reindented.is_empty() {
continue;
}
let char_ops = streaming_diff.push_new(&reindented);
apply_char_operations(
&char_ops,
&self.buffer,
original_snapshot,
edit_cursor,
&tool.action_log,
cx,
);
let position = original_snapshot.anchor_before(*edit_cursor);
cx.update(|cx| {
tool.set_agent_location(self.buffer.downgrade(), position, cx);
});
}
ToolEditEvent::NewTextChunk {
chunk, done: true, ..
} => {
log::debug!("new_text_chunk: done=true, chunk='{}'", chunk);
let Some(EditPipelineEntry::StreamingNewText {
mut streaming_diff,
mut edit_cursor,
mut reindenter,
original_snapshot,
}) = self.pipeline.current_edit.take()
else {
continue;
};
// Flush any remaining reindent buffer + final chunk.
let mut final_text = reindenter.push(chunk);
final_text.push_str(&reindenter.finish());
log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
if !final_text.is_empty() {
let char_ops = streaming_diff.push_new(&final_text);
apply_char_operations(
&char_ops,
&self.buffer,
&original_snapshot,
&mut edit_cursor,
&tool.action_log,
cx,
);
}
let remaining_ops = streaming_diff.finish();
apply_char_operations(
&remaining_ops,
&self.buffer,
&original_snapshot,
&mut edit_cursor,
&tool.action_log,
cx,
);
let position = original_snapshot.anchor_before(edit_cursor);
cx.update(|cx| {
tool.set_agent_location(self.buffer.downgrade(), position, cx);
});
}
}
}
Ok(())

View file

@ -2,9 +2,9 @@ use smallvec::SmallVec;
use crate::{Edit, PartialEdit};
/// Events emitted by `ToolEditParser` as tool call input streams in.
/// Events emitted by `StreamingParser` for edit-mode input.
#[derive(Debug, PartialEq, Eq)]
pub enum ToolEditEvent {
pub enum EditEvent {
/// A chunk of `old_text` for an edit operation.
OldTextChunk {
edit_index: usize,
@ -17,6 +17,11 @@ pub enum ToolEditEvent {
chunk: String,
done: bool,
},
}
/// Events emitted by `StreamingParser` for write-mode input.
#[derive(Debug, PartialEq, Eq)]
pub enum WriteEvent {
/// A chunk of content for write/overwrite mode.
ContentChunk { chunk: String },
}
@ -34,9 +39,9 @@ struct EditStreamState {
///
/// The tool call streaming infrastructure delivers partial JSON objects where
/// string fields grow over time. This parser compares consecutive partials,
/// computes the deltas, and emits `ToolEditEvent`s that downstream pipeline
/// stages (`StreamingFuzzyMatcher` for old_text, `StreamingDiff` for new_text)
/// can consume incrementally.
/// computes the deltas, and emits `EditEvent`s or `WriteEvent`s that downstream
/// pipeline stages (`StreamingFuzzyMatcher` for old_text, `StreamingDiff` for
/// new_text) can consume incrementally.
///
/// Because partial JSON comes through a fixer (`partial-json-fixer`) that
/// closes incomplete escape sequences, a string can temporarily contain wrong
@ -46,18 +51,18 @@ struct EditStreamState {
/// next partial confirms or corrects it. This avoids feeding corrupted bytes
/// to downstream consumers.
#[derive(Default, Debug)]
pub struct ToolEditParser {
pub struct StreamingParser {
edit_states: Vec<EditStreamState>,
content_emitted_len: usize,
}
impl ToolEditParser {
impl StreamingParser {
/// Push a new set of partial edits (from edit mode) and return any events.
///
/// Each call should pass the *entire current* edits array as seen in the
/// latest partial input. The parser will diff it against its internal state
/// to produce only the new events.
pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[ToolEditEvent; 4]> {
pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[EditEvent; 4]> {
let mut events = SmallVec::new();
for (index, partial) in edits.iter().enumerate() {
@ -81,7 +86,7 @@ impl ToolEditParser {
let chunk = normalize_done_chunk(old_text[start..].to_string());
state.old_text_done = true;
state.old_text_emitted_len = old_text.len();
events.push(ToolEditEvent::OldTextChunk {
events.push(EditEvent::OldTextChunk {
edit_index: index,
chunk,
done: true,
@ -92,7 +97,7 @@ impl ToolEditParser {
if safe_end > state.old_text_emitted_len {
let chunk = old_text[state.old_text_emitted_len..safe_end].to_string();
state.old_text_emitted_len = safe_end;
events.push(ToolEditEvent::OldTextChunk {
events.push(EditEvent::OldTextChunk {
edit_index: index,
chunk,
done: false,
@ -110,7 +115,7 @@ impl ToolEditParser {
if safe_end > state.new_text_emitted_len {
let chunk = new_text[state.new_text_emitted_len..safe_end].to_string();
state.new_text_emitted_len = safe_end;
events.push(ToolEditEvent::NewTextChunk {
events.push(EditEvent::NewTextChunk {
edit_index: index,
chunk,
done: false,
@ -126,14 +131,14 @@ impl ToolEditParser {
///
/// Each call should pass the *entire current* content string. The parser
/// will diff it against its internal state to emit only the new chunk.
pub fn push_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
pub fn push_content(&mut self, content: &str) -> SmallVec<[WriteEvent; 1]> {
let mut events = SmallVec::new();
let safe_end = safe_emit_end(content);
if safe_end > self.content_emitted_len {
let chunk = content[self.content_emitted_len..safe_end].to_string();
self.content_emitted_len = safe_end;
events.push(ToolEditEvent::ContentChunk { chunk });
events.push(WriteEvent::ContentChunk { chunk });
}
events
@ -146,7 +151,7 @@ impl ToolEditParser {
/// `final_edits` should be the fully deserialized final edits array. The
/// parser compares against its tracked state and emits any remaining deltas
/// with `done: true`.
pub fn finalize_edits(&mut self, edits: &[Edit]) -> SmallVec<[ToolEditEvent; 4]> {
pub fn finalize_edits(&mut self, edits: &[Edit]) -> SmallVec<[EditEvent; 4]> {
let mut events = SmallVec::new();
for (index, edit) in edits.iter().enumerate() {
@ -165,7 +170,7 @@ impl ToolEditParser {
let chunk = normalize_done_chunk(edit.old_text[start..].to_string());
state.old_text_done = true;
state.old_text_emitted_len = edit.old_text.len();
events.push(ToolEditEvent::OldTextChunk {
events.push(EditEvent::OldTextChunk {
edit_index: index,
chunk,
done: true,
@ -177,7 +182,7 @@ impl ToolEditParser {
let chunk = normalize_done_chunk(edit.new_text[start..].to_string());
state.new_text_done = true;
state.new_text_emitted_len = edit.new_text.len();
events.push(ToolEditEvent::NewTextChunk {
events.push(EditEvent::NewTextChunk {
edit_index: index,
chunk,
done: true,
@ -189,14 +194,14 @@ impl ToolEditParser {
}
/// Finalize content with the complete input.
pub fn finalize_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
pub fn finalize_content(&mut self, content: &str) -> SmallVec<[WriteEvent; 1]> {
let mut events = SmallVec::new();
let start = self.content_emitted_len.min(content.len());
if content.len() > start {
let chunk = content[start..].to_string();
self.content_emitted_len = content.len();
events.push(ToolEditEvent::ContentChunk { chunk });
events.push(WriteEvent::ContentChunk { chunk });
}
events
@ -204,7 +209,7 @@ impl ToolEditParser {
/// 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<SmallVec<[ToolEditEvent; 2]>> {
fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[EditEvent; 2]>> {
if new_index == 0 || self.edit_states.is_empty() {
return None;
}
@ -220,7 +225,7 @@ impl ToolEditParser {
// If old_text was never finalized, finalize it now with an empty done chunk.
if !state.old_text_done {
state.old_text_done = true;
events.push(ToolEditEvent::OldTextChunk {
events.push(EditEvent::OldTextChunk {
edit_index: previous_index,
chunk: String::new(),
done: true,
@ -230,7 +235,7 @@ impl ToolEditParser {
// Emit a done event for new_text if not already finalized.
if !state.new_text_done {
state.new_text_done = true;
events.push(ToolEditEvent::NewTextChunk {
events.push(EditEvent::NewTextChunk {
edit_index: previous_index,
chunk: String::new(),
done: true,
@ -276,7 +281,7 @@ mod tests {
#[test]
fn test_single_edit_streamed_incrementally() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// old_text arrives in chunks: "hell" → "hello w" → "hello world"
let events = parser.push_edits(&[PartialEdit {
@ -285,7 +290,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "hell".into(),
done: false,
@ -298,7 +303,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "o w".into(),
done: false,
@ -313,12 +318,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "orld".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "good".into(),
done: false,
@ -333,7 +338,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::NewTextChunk {
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: "bye world".into(),
done: false,
@ -347,7 +352,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::NewTextChunk {
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
@ -357,7 +362,7 @@ mod tests {
#[test]
fn test_done_chunks_strip_trailing_newline() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.finalize_edits(&[Edit {
old_text: "before\n".into(),
@ -366,12 +371,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "before".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "after".into(),
done: true,
@ -382,7 +387,7 @@ mod tests {
#[test]
fn test_partial_edit_chunks_hold_back_trailing_newline() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("before\n".into()),
@ -391,12 +396,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "before".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "after".into(),
done: false,
@ -410,7 +415,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::NewTextChunk {
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
@ -420,7 +425,7 @@ mod tests {
#[test]
fn test_multiple_edits_sequential() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// First edit streams in
let events = parser.push_edits(&[PartialEdit {
@ -429,7 +434,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "first old".into(),
done: false,
@ -443,12 +448,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "first new".into(),
done: false,
@ -470,12 +475,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
},
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 1,
chunk: "second".into(),
done: false,
@ -497,12 +502,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 1,
chunk: " old".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 1,
chunk: "second new".into(),
done: true,
@ -513,12 +518,12 @@ mod tests {
#[test]
fn test_content_streamed_incrementally() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.push_content("hello");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk {
&[WriteEvent::ContentChunk {
chunk: "hello".into(),
}]
);
@ -526,7 +531,7 @@ mod tests {
let events = parser.push_content("hello world");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk {
&[WriteEvent::ContentChunk {
chunk: " world".into(),
}]
);
@ -538,7 +543,7 @@ mod tests {
let events = parser.push_content("hello world!");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk { chunk: "!".into() }]
&[WriteEvent::ContentChunk { chunk: "!".into() }]
);
// Finalize with no additional content
@ -548,13 +553,13 @@ mod tests {
#[test]
fn test_finalize_content_with_remaining() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
parser.push_content("partial");
let events = parser.finalize_content("partial content here");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk {
&[WriteEvent::ContentChunk {
chunk: " content here".into(),
}]
);
@ -562,14 +567,14 @@ mod tests {
#[test]
fn test_content_trailing_backslash_held_back() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Partial JSON fixer turns incomplete \n into \\ (literal backslash).
// The trailing backslash is held back.
let events = parser.push_content("hello,\\");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk {
&[WriteEvent::ContentChunk {
chunk: "hello,".into(),
}]
);
@ -579,14 +584,14 @@ mod tests {
let events = parser.push_content("hello,\n");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
&[WriteEvent::ContentChunk { chunk: "\n".into() }]
);
// Normal growth.
let events = parser.push_content("hello,\nworld");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk {
&[WriteEvent::ContentChunk {
chunk: "world".into(),
}]
);
@ -594,7 +599,7 @@ mod tests {
#[test]
fn test_content_finalize_with_trailing_backslash() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Stream a partial with a fixer-corrupted trailing backslash.
// The backslash is held back.
@ -604,13 +609,13 @@ mod tests {
let events = parser.finalize_content("abc\n");
assert_eq!(
events.as_slice(),
&[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
&[WriteEvent::ContentChunk { chunk: "\n".into() }]
);
}
#[test]
fn test_no_partials_direct_finalize() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.finalize_edits(&[Edit {
old_text: "old".into(),
@ -619,12 +624,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "old".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "new".into(),
done: true,
@ -635,7 +640,7 @@ mod tests {
#[test]
fn test_no_partials_direct_finalize_multiple() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.finalize_edits(&[
Edit {
@ -650,22 +655,22 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "first old".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "first new".into(),
done: true,
},
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 1,
chunk: "second old".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 1,
chunk: "second new".into(),
done: true,
@ -676,7 +681,7 @@ mod tests {
#[test]
fn test_old_text_no_growth() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("same".into()),
@ -684,7 +689,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "same".into(),
done: false,
@ -701,7 +706,7 @@ mod tests {
#[test]
fn test_old_text_none_then_appears() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Edit exists but old_text is None (field hasn't arrived yet)
let events = parser.push_edits(&[PartialEdit {
@ -717,7 +722,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "text".into(),
done: false,
@ -727,7 +732,7 @@ mod tests {
#[test]
fn test_empty_old_text_with_new_text() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// old_text is empty, new_text appears immediately
let events = parser.push_edits(&[PartialEdit {
@ -737,12 +742,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "inserted".into(),
done: false,
@ -753,7 +758,7 @@ mod tests {
#[test]
fn test_three_edits_streamed() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Stream first edit
parser.push_edits(&[PartialEdit {
@ -793,12 +798,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 1,
chunk: "".into(),
done: true,
},
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 2,
chunk: "c".into(),
done: false,
@ -824,12 +829,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 2,
chunk: "".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 2,
chunk: "C".into(),
done: true,
@ -840,7 +845,7 @@ mod tests {
#[test]
fn test_finalize_with_unseen_old_text() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Only saw partial old_text, never saw new_text in partials
parser.push_edits(&[PartialEdit {
@ -855,12 +860,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: " old text".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "replacement".into(),
done: true,
@ -871,7 +876,7 @@ mod tests {
#[test]
fn test_finalize_with_partially_seen_new_text() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
parser.push_edits(&[PartialEdit {
old_text: Some("old".into()),
@ -884,7 +889,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::NewTextChunk {
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: " new text".into(),
done: true,
@ -894,7 +899,7 @@ mod tests {
#[test]
fn test_repeated_pushes_with_no_change() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("stable".into()),
@ -919,7 +924,7 @@ mod tests {
#[test]
fn test_old_text_trailing_backslash_held_back() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
// Partial-json-fixer produces a literal backslash when the JSON stream
// cuts in the middle of an escape sequence like \n. The parser holds
@ -931,7 +936,7 @@ mod tests {
// The trailing `\` is held back — only "hello," is emitted.
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "hello,".into(),
done: false,
@ -955,7 +960,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "\nworld".into(),
done: false,
@ -965,7 +970,7 @@ mod tests {
#[test]
fn test_multiline_old_and_new_text() {
let mut parser = ToolEditParser::default();
let mut parser = StreamingParser::default();
let events = parser.push_edits(&[PartialEdit {
old_text: Some("line1\nline2".into()),
@ -973,7 +978,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::OldTextChunk {
&[EditEvent::OldTextChunk {
edit_index: 0,
chunk: "line1\nline2".into(),
done: false,
@ -987,12 +992,12 @@ mod tests {
assert_eq!(
events.as_slice(),
&[
ToolEditEvent::OldTextChunk {
EditEvent::OldTextChunk {
edit_index: 0,
chunk: "\nline3".into(),
done: true,
},
ToolEditEvent::NewTextChunk {
EditEvent::NewTextChunk {
edit_index: 0,
chunk: "LINE1".into(),
done: false,
@ -1006,7 +1011,7 @@ mod tests {
}]);
assert_eq!(
events.as_slice(),
&[ToolEditEvent::NewTextChunk {
&[EditEvent::NewTextChunk {
edit_index: 0,
chunk: "\nLINE2\nLINE3".into(),
done: false,