mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
11371e6e1b
commit
8f2ab516d0
3 changed files with 393 additions and 346 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
Loading…
Reference in a new issue