@@ -1,13 +1,17 @@
use super::edit_file_tool::EditFileTool;
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
+use super::tool_edit_parser::{ToolEditEvent, ToolEditParser};
use crate::{
AgentTool, Thread, ToolCallEventStream, ToolInput,
- edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher,
+ edit_agent::{
+ reindent::{Reindenter, compute_indent_delta},
+ streaming_fuzzy_matcher::StreamingFuzzyMatcher,
+ },
};
use acp_thread::Diff;
use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
use collections::HashSet;
use futures::FutureExt as _;
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
@@ -15,16 +19,15 @@ use language::language_settings::{self, FormatOnSave};
use language::{Buffer, LanguageRegistry};
use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
-use project::{Project, ProjectPath};
+use project::{AgentLocation, Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc;
-use text::{BufferSnapshot, ToOffset as _};
+use streaming_diff::{CharOperation, StreamingDiff};
use ui::SharedString;
use util::rel_path::RelPath;
-use util::{Deferred, ResultExt, debug_panic};
+use util::{Deferred, ResultExt};
const DEFAULT_UI_TEXT: &str = "Editing file";
@@ -70,14 +73,13 @@ pub struct StreamingEditFileToolInput {
pub path: String,
/// The mode of operation on the file. Possible values:
- /// - 'create': Create a new file if it doesn't exist. Requires 'content' field.
- /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field.
+ /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field.
/// - 'edit': Make granular edits to an existing file. Requires 'edits' field.
///
/// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
pub mode: StreamingEditFileMode,
- /// The complete content for the new file (required for 'create' and 'overwrite' modes).
+ /// The complete content for the new file (required for 'write' mode).
/// This field should contain the entire file content.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
@@ -85,23 +87,22 @@ pub struct StreamingEditFileToolInput {
/// List of edit operations to apply sequentially (required for 'edit' mode).
/// Each edit finds `old_text` in the file and replaces it with `new_text`.
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub edits: Option<Vec<EditOperation>>,
+ pub edits: Option<Vec<Edit>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StreamingEditFileMode {
- /// Create a new file if it doesn't exist
- Create,
- /// Replace the entire contents of an existing file
- Overwrite,
+ /// Overwrite the file with new content (replacing any existing content).
+ /// If the file does not exist, it will be created.
+ Write,
/// Make granular edits to an existing file
Edit,
}
/// A single edit operation that replaces old text with new text
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
-pub struct EditOperation {
+pub struct Edit {
/// The exact text to find in the file. This will be matched using fuzzy matching
/// to handle minor differences in whitespace or formatting.
pub old_text: String,
@@ -118,271 +119,328 @@ struct StreamingEditFileToolPartialInput {
#[serde(default)]
mode: Option<StreamingEditFileMode>,
#[serde(default)]
- #[allow(dead_code)]
content: Option<String>,
#[serde(default)]
- edits: Option<Vec<PartialEditOperation>>,
+ edits: Option<Vec<PartialEdit>>,
}
#[derive(Default, Debug, Deserialize)]
-struct PartialEditOperation {
+pub struct PartialEdit {
#[serde(default)]
- old_text: Option<String>,
+ pub old_text: Option<String>,
#[serde(default)]
- new_text: Option<String>,
+ pub new_text: Option<String>,
}
-enum StreamingEditState {
- Idle,
- BufferResolved {
- abs_path: PathBuf,
- buffer: Entity<Buffer>,
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum StreamingEditFileToolOutput {
+ Success {
+ #[serde(alias = "original_path")]
+ input_path: PathBuf,
+ new_text: String,
old_text: Arc<String>,
- diff: Entity<Diff>,
- mode: StreamingEditFileMode,
- last_content_len: usize,
- edit_state: IncrementalEditState,
- _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
+ #[serde(default)]
+ diff: String,
+ },
+ Error {
+ error: String,
},
}
-#[derive(Default)]
-struct IncrementalEditState {
- in_progress_matcher: Option<StreamingFuzzyMatcher>,
- last_old_text_len: usize,
- applied_ranges: Vec<Range<text::Anchor>>,
+impl StreamingEditFileToolOutput {
+ pub fn error(error: impl Into<String>) -> Self {
+ Self::Error {
+ error: error.into(),
+ }
+ }
+}
+
+impl std::fmt::Display for StreamingEditFileToolOutput {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ StreamingEditFileToolOutput::Success {
+ diff, input_path, ..
+ } => {
+ if diff.is_empty() {
+ write!(f, "No edits were made.")
+ } else {
+ write!(
+ f,
+ "Edited {}:\n\n```diff\n{diff}\n```",
+ input_path.display()
+ )
+ }
+ }
+ StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),
+ }
+ }
}
-impl IncrementalEditState {
- fn applied_count(&self) -> usize {
- self.applied_ranges.len()
+impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {
+ fn from(output: StreamingEditFileToolOutput) -> Self {
+ output.to_string().into()
}
}
-impl StreamingEditState {
- async fn finalize(
- &mut self,
- input: StreamingEditFileToolInput,
- tool: &StreamingEditFileTool,
+pub struct StreamingEditFileTool {
+ thread: WeakEntity<Thread>,
+ language_registry: Arc<LanguageRegistry>,
+ project: Entity<Project>,
+}
+
+impl StreamingEditFileTool {
+ pub fn new(
+ project: Entity<Project>,
+ thread: WeakEntity<Thread>,
+ language_registry: Arc<LanguageRegistry>,
+ ) -> Self {
+ Self {
+ project,
+ thread,
+ language_registry,
+ }
+ }
+
+ fn authorize(
+ &self,
+ path: &PathBuf,
+ description: &str,
event_stream: &ToolCallEventStream,
- cx: &mut AsyncApp,
- ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
- let remaining_edits_start_ix = match self {
- StreamingEditState::Idle => {
- *self = Self::transition_to_buffer_resolved(
- &input.path,
- &input.display_description,
- input.mode.clone(),
- tool,
- event_stream,
- cx,
- )
- .await?;
- 0
- }
- StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(),
- };
+ cx: &mut App,
+ ) -> Task<Result<()>> {
+ super::tool_permissions::authorize_file_edit(
+ EditFileTool::NAME,
+ path,
+ description,
+ &self.thread,
+ event_stream,
+ cx,
+ )
+ }
- let StreamingEditState::BufferResolved {
- buffer,
- old_text,
- diff,
- abs_path,
- ..
- } = self
- else {
- debug_panic!("Invalid state");
- return Ok(StreamingEditFileToolOutput::Error {
- error: "Internal error. Try to apply the edits again".to_string(),
- });
- };
+ fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
+ self.project.update(cx, |project, cx| {
+ project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
+ });
+ }
+}
- let result: anyhow::Result<StreamingEditFileToolOutput> = async {
- let action_log = tool
- .thread
- .read_with(cx, |thread, _cx| thread.action_log().clone())?;
+impl AgentTool for StreamingEditFileTool {
+ type Input = StreamingEditFileToolInput;
+ type Output = StreamingEditFileToolOutput;
- match input.mode {
- StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
- action_log.update(cx, |log, cx| {
- log.buffer_created(buffer.clone(), cx);
- });
- let content = input.content.ok_or_else(|| {
- anyhow!("'content' field is required for create and overwrite modes")
- })?;
- cx.update(|cx| {
- buffer.update(cx, |buffer, cx| {
- buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
- });
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
- });
- }
- StreamingEditFileMode::Edit => {
- let edits = input
- .edits
- .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?;
-
- let remaining_edits = &edits[remaining_edits_start_ix..];
- apply_edits(
- &buffer,
- &action_log,
- remaining_edits,
- &diff,
- event_stream,
- &abs_path,
- cx,
- )?;
- }
- }
+ const NAME: &'static str = "streaming_edit_file";
- let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
- let settings = language_settings::language_settings(
- buffer.language().map(|l| l.name()),
- buffer.file(),
- cx,
- );
- settings.format_on_save != FormatOnSave::Off
- });
+ fn supports_input_streaming() -> bool {
+ true
+ }
- if format_on_save_enabled {
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
+ fn kind() -> acp::ToolKind {
+ acp::ToolKind::Edit
+ }
- let format_task = tool.project.update(cx, |project, cx| {
- project.format(
- HashSet::from_iter([buffer.clone()]),
- LspFormatTarget::Buffers,
- false,
- FormatTrigger::Save,
- cx,
- )
- });
- futures::select! {
- result = format_task.fuse() => { result.log_err(); },
- _ = event_stream.cancelled_by_user().fuse() => {
- anyhow::bail!("Edit cancelled by user");
+ fn initial_title(
+ &self,
+ input: Result<Self::Input, serde_json::Value>,
+ cx: &mut App,
+ ) -> SharedString {
+ match input {
+ Ok(input) => self
+ .project
+ .read(cx)
+ .find_project_path(&input.path, cx)
+ .and_then(|project_path| {
+ self.project
+ .read(cx)
+ .short_full_path_for_project_path(&project_path, cx)
+ })
+ .unwrap_or(input.path)
+ .into(),
+ Err(raw_input) => {
+ if let Some(input) =
+ serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()
+ {
+ let path = input.path.unwrap_or_default();
+ let path = path.trim();
+ if !path.is_empty() {
+ return self
+ .project
+ .read(cx)
+ .find_project_path(&path, cx)
+ .and_then(|project_path| {
+ self.project
+ .read(cx)
+ .short_full_path_for_project_path(&project_path, cx)
+ })
+ .unwrap_or_else(|| path.to_string())
+ .into();
}
- };
- }
- let save_task = tool
- .project
- .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
- futures::select! {
- result = save_task.fuse() => { result?; },
- _ = event_stream.cancelled_by_user().fuse() => {
- anyhow::bail!("Edit cancelled by user");
+ let description = input.display_description.unwrap_or_default();
+ let description = description.trim();
+ if !description.is_empty() {
+ return description.to_string().into();
+ }
}
- };
-
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
- if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
- buffer.file().and_then(|file| file.disk_state().mtime())
- }) {
- tool.thread.update(cx, |thread, _| {
- thread
- .file_read_times
- .insert(abs_path.to_path_buf(), new_mtime);
- })?;
+ DEFAULT_UI_TEXT.into()
}
+ }
+ }
- let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
- let (new_text, unified_diff) = cx
- .background_spawn({
- let new_snapshot = new_snapshot.clone();
- let old_text = old_text.clone();
- async move {
- let new_text = new_snapshot.text();
- let diff = language::unified_diff(&old_text, &new_text);
- (new_text, diff)
+ fn run(
+ self: Arc<Self>,
+ mut input: ToolInput<Self::Input>,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task<Result<Self::Output, Self::Output>> {
+ cx.spawn(async move |cx: &mut AsyncApp| {
+ let mut state: Option<EditSession> = None;
+ loop {
+ futures::select! {
+ partial = input.recv_partial().fuse() => {
+ let Some(partial_value) = partial else { break };
+ if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
+ if state.is_none() && let Some(path_str) = &parsed.path
+ && let Some(display_description) = &parsed.display_description
+ && let Some(mode) = parsed.mode.clone() {
+ state = Some(
+ EditSession::new(
+ path_str,
+ display_description,
+ mode,
+ &self,
+ &event_stream,
+ cx,
+ )
+ .await?,
+ );
+ }
+
+ if let Some(state) = &mut state {
+ state.process(parsed, &self, &event_stream, cx)?;
+ }
+ }
}
- })
- .await;
+ _ = event_stream.cancelled_by_user().fuse() => {
+ return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
+ }
+ }
+ }
+ let full_input =
+ input
+ .recv()
+ .await
+ .map_err(|e| StreamingEditFileToolOutput::error(format!("Failed to receive tool input: {e}")))?;
- let output = StreamingEditFileToolOutput::Success {
- input_path: PathBuf::from(input.path),
- new_text,
- old_text: old_text.clone(),
- diff: unified_diff,
+ let mut state = if let Some(state) = state {
+ state
+ } else {
+ EditSession::new(
+ &full_input.path,
+ &full_input.display_description,
+ full_input.mode.clone(),
+ &self,
+ &event_stream,
+ cx,
+ )
+ .await?
};
- Ok(output)
- }
- .await;
- result.map_err(|e| StreamingEditFileToolOutput::Error {
- error: e.to_string(),
+ state.finalize(full_input, &self, &event_stream, cx).await
})
}
- async fn process(
- &mut self,
- partial: StreamingEditFileToolPartialInput,
- tool: &StreamingEditFileTool,
- event_stream: &ToolCallEventStream,
- cx: &mut AsyncApp,
- ) -> Result<(), StreamingEditFileToolOutput> {
- match self {
- Self::Idle => {
- if let Some(path_str) = partial.path
- && let Some(display_description) = partial.display_description
- && let Some(mode) = partial.mode
- {
- *self = Self::transition_to_buffer_resolved(
- &path_str,
- &display_description,
- mode,
- tool,
- event_stream,
+ fn replay(
+ &self,
+ _input: Self::Input,
+ output: Self::Output,
+ event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Result<()> {
+ match output {
+ StreamingEditFileToolOutput::Success {
+ input_path,
+ old_text,
+ new_text,
+ ..
+ } => {
+ event_stream.update_diff(cx.new(|cx| {
+ Diff::finalized(
+ input_path.to_string_lossy().into_owned(),
+ Some(old_text.to_string()),
+ new_text,
+ self.language_registry.clone(),
cx,
)
- .await?;
- }
+ }));
+ Ok(())
}
- Self::BufferResolved {
- abs_path,
- buffer,
- edit_state,
- diff,
- mode,
- last_content_len,
- ..
- } => match mode {
- StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
- if let Some(content) = &partial.content {
- Self::process_streaming_content(
- buffer,
- diff,
- last_content_len,
- content,
- cx,
- )?;
- }
- }
- StreamingEditFileMode::Edit => {
- if let Some(edits) = partial.edits {
- Self::process_streaming_edits(
- buffer,
- diff,
- edit_state,
- &edits,
- abs_path,
- tool,
- event_stream,
- cx,
- )?;
- }
- }
- },
+ StreamingEditFileToolOutput::Error { .. } => Ok(()),
+ }
+ }
+}
+
+pub struct EditSession {
+ abs_path: PathBuf,
+ buffer: Entity<Buffer>,
+ old_text: Arc<String>,
+ diff: Entity<Diff>,
+ mode: StreamingEditFileMode,
+ parser: ToolEditParser,
+ pipeline: EditPipeline,
+ _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
+}
+
+struct EditPipeline {
+ edits: Vec<EditPipelineEntry>,
+ content_written: bool,
+}
+
+enum EditPipelineEntry {
+ ResolvingOldText {
+ matcher: StreamingFuzzyMatcher,
+ },
+ StreamingNewText {
+ streaming_diff: StreamingDiff,
+ edit_cursor: usize,
+ reindenter: Reindenter,
+ original_snapshot: text::BufferSnapshot,
+ },
+ Done,
+}
+
+impl EditPipeline {
+ fn new() -> Self {
+ Self {
+ edits: Vec::new(),
+ content_written: false,
}
- Ok(())
}
- async fn transition_to_buffer_resolved(
+ fn ensure_resolving_old_text(
+ &mut self,
+ edit_index: usize,
+ buffer: &Entity<Buffer>,
+ cx: &mut AsyncApp,
+ ) {
+ while self.edits.len() <= edit_index {
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
+ self.edits.push(EditPipelineEntry::ResolvingOldText {
+ matcher: StreamingFuzzyMatcher::new(snapshot),
+ });
+ }
+ }
+}
+
+/// Compute the `LineIndent` of the first line in a set of query lines.
+fn query_first_line_indent(query_lines: &[String]) -> text::LineIndent {
+ let first_line = query_lines.first().map(|s| s.as_str()).unwrap_or("");
+ text::LineIndent::from_iter(first_line.chars())
+}
+
+impl EditSession {
+ async fn new(
path_str: &str,
display_description: &str,
mode: StreamingEditFileMode,
@@ -393,15 +451,13 @@ impl StreamingEditState {
let path = PathBuf::from(path_str);
let project_path = cx
.update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx))
- .map_err(|e| StreamingEditFileToolOutput::Error {
- error: e.to_string(),
- })?;
+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
else {
- return Err(StreamingEditFileToolOutput::Error {
- error: format!("File '{path_str}' does not exist"),
- });
+ return Err(StreamingEditFileToolOutput::error(format!(
+ "Worktree at '{path_str}' does not exist"
+ )));
};
event_stream.update_fields(
@@ -410,17 +466,13 @@ impl StreamingEditState {
cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
.await
- .map_err(|e| StreamingEditFileToolOutput::Error {
- error: e.to_string(),
- })?;
+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
let buffer = tool
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await
- .map_err(|e| StreamingEditFileToolOutput::Error {
- error: e.to_string(),
- })?;
+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
@@ -434,6 +486,14 @@ impl StreamingEditState {
}
}) as Box<dyn FnOnce()>);
+ tool.thread
+ .update(cx, |thread, cx| {
+ thread
+ .action_log()
+ .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx))
+ })
+ .ok();
+
let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
let old_text = cx
.background_spawn({
@@ -442,205 +502,497 @@ impl StreamingEditState {
})
.await;
- Ok(Self::BufferResolved {
+ Ok(Self {
abs_path,
buffer,
old_text,
diff,
mode,
- last_content_len: 0,
- edit_state: IncrementalEditState::default(),
+ parser: ToolEditParser::default(),
+ pipeline: EditPipeline::new(),
_finalize_diff_guard: finalize_diff_guard,
})
}
- fn process_streaming_content(
- buffer: &Entity<Buffer>,
- diff: &Entity<Diff>,
- last_content_len: &mut usize,
- content: &str,
+ async fn finalize(
+ &mut self,
+ input: StreamingEditFileToolInput,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
- ) -> Result<(), StreamingEditFileToolOutput> {
- let new_len = content.len();
- if new_len > *last_content_len {
- let new_chunk = &content[*last_content_len..];
- cx.update(|cx| {
- buffer.update(cx, |buffer, cx| {
- // On the first update, replace the entire buffer (handles Overwrite
- // clearing existing content). For Create the buffer is already empty
- // so 0..0 is a no-op range prefix.
- let insert_at = if *last_content_len == 0 {
- 0..buffer.len()
- } else {
- let len = buffer.len();
- len..len
- };
- buffer.edit([(insert_at, new_chunk)], None, cx);
+ ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> {
+ let Self {
+ buffer,
+ old_text,
+ diff,
+ abs_path,
+ parser,
+ pipeline,
+ ..
+ } = self;
+
+ let action_log = tool
+ .thread
+ .read_with(cx, |thread, _cx| thread.action_log().clone())
+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
+
+ match input.mode {
+ StreamingEditFileMode::Write => {
+ action_log.update(cx, |log, cx| {
+ log.buffer_created(buffer.clone(), cx);
});
+ let content = input.content.ok_or_else(|| {
+ StreamingEditFileToolOutput::error("'content' field is required for write mode")
+ })?;
+
+ let events = parser.finalize_content(&content);
+ Self::process_events(
+ &events,
+ buffer,
+ diff,
+ pipeline,
+ abs_path,
+ tool,
+ event_stream,
+ cx,
+ )?;
+ }
+ StreamingEditFileMode::Edit => {
+ let edits = input.edits.ok_or_else(|| {
+ StreamingEditFileToolOutput::error("'edits' field is required for edit mode")
+ })?;
+
+ let final_edits = edits
+ .into_iter()
+ .map(|e| Edit {
+ old_text: e.old_text,
+ new_text: e.new_text,
+ })
+ .collect::<Vec<_>>();
+ let events = parser.finalize_edits(&final_edits);
+ Self::process_events(
+ &events,
+ buffer,
+ diff,
+ pipeline,
+ abs_path,
+ tool,
+ event_stream,
+ cx,
+ )?;
+ }
+ }
+
+ let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
+ let settings = language_settings::language_settings(
+ buffer.language().map(|l| l.name()),
+ buffer.file(),
+ cx,
+ );
+ settings.format_on_save != FormatOnSave::Off
+ });
+
+ if format_on_save_enabled {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
});
- *last_content_len = new_len;
- let anchor_range = buffer.read_with(cx, |buffer, _cx| {
- buffer.anchor_range_between(0..buffer.len())
+ let format_task = tool.project.update(cx, |project, cx| {
+ project.format(
+ HashSet::from_iter([buffer.clone()]),
+ LspFormatTarget::Buffers,
+ false,
+ FormatTrigger::Save,
+ cx,
+ )
});
- diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
+ futures::select! {
+ result = format_task.fuse() => { result.log_err(); },
+ _ = event_stream.cancelled_by_user().fuse() => {
+ return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
+ }
+ };
+ }
+
+ let save_task = tool
+ .project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
+ futures::select! {
+ result = save_task.fuse() => { result.map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; },
+ _ = event_stream.cancelled_by_user().fuse() => {
+ return Err(StreamingEditFileToolOutput::error("Edit cancelled by user"));
+ }
+ };
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ });
+
+ if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
+ buffer.file().and_then(|file| file.disk_state().mtime())
+ }) {
+ tool.thread
+ .update(cx, |thread, _| {
+ thread
+ .file_read_times
+ .insert(abs_path.to_path_buf(), new_mtime);
+ })
+ .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?;
+ }
+
+ let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+ let (new_text, unified_diff) = cx
+ .background_spawn({
+ let new_snapshot = new_snapshot.clone();
+ let old_text = old_text.clone();
+ async move {
+ let new_text = new_snapshot.text();
+ let diff = language::unified_diff(&old_text, &new_text);
+ (new_text, diff)
+ }
+ })
+ .await;
+
+ let output = StreamingEditFileToolOutput::Success {
+ input_path: PathBuf::from(input.path),
+ new_text,
+ old_text: old_text.clone(),
+ diff: unified_diff,
+ };
+ Ok(output)
+ }
+
+ fn process(
+ &mut self,
+ partial: StreamingEditFileToolPartialInput,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result<(), StreamingEditFileToolOutput> {
+ match &self.mode {
+ StreamingEditFileMode::Write => {
+ if let Some(content) = &partial.content {
+ let events = self.parser.push_content(content);
+ Self::process_events(
+ &events,
+ &self.buffer,
+ &self.diff,
+ &mut self.pipeline,
+ &self.abs_path,
+ tool,
+ event_stream,
+ cx,
+ )?;
+ }
+ }
+ StreamingEditFileMode::Edit => {
+ if let Some(edits) = partial.edits {
+ let events = self.parser.push_edits(&edits);
+ Self::process_events(
+ &events,
+ &self.buffer,
+ &self.diff,
+ &mut self.pipeline,
+ &self.abs_path,
+ tool,
+ event_stream,
+ cx,
+ )?;
+ }
+ }
}
Ok(())
}
- fn process_streaming_edits(
+ fn process_events(
+ events: &[ToolEditEvent],
buffer: &Entity<Buffer>,
diff: &Entity<Diff>,
- edit_state: &mut IncrementalEditState,
- edits: &[PartialEditOperation],
+ pipeline: &mut EditPipeline,
abs_path: &PathBuf,
tool: &StreamingEditFileTool,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<(), StreamingEditFileToolOutput> {
- if edits.is_empty() {
- return Ok(());
- }
+ for event in events {
+ match event {
+ ToolEditEvent::ContentChunk { chunk } => {
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| {
+ let insert_at = if !pipeline.content_written && buffer.len() > 0 {
+ 0..buffer.len()
+ } else {
+ let len = buffer.len();
+ len..len
+ };
+ buffer.edit([(insert_at, chunk.as_str())], None, cx);
+ });
+ let buffer_id = buffer.read(cx).remote_id();
+ tool.set_agent_location(
+ buffer.downgrade(),
+ text::Anchor::max_for_buffer(buffer_id),
+ cx,
+ );
+ });
+ pipeline.content_written = true;
+ }
- // Edits at indices applied_count..edits.len()-1 are newly complete
- // (a subsequent edit exists, proving the LLM moved on).
- // The last edit (edits.len()-1) is potentially still in progress.
- let completed_count = edits.len().saturating_sub(1);
+ ToolEditEvent::OldTextChunk {
+ edit_index,
+ chunk,
+ done: false,
+ } => {
+ pipeline.ensure_resolving_old_text(*edit_index, buffer, cx);
+
+ if let EditPipelineEntry::ResolvingOldText { matcher } =
+ &mut pipeline.edits[*edit_index]
+ {
+ if !chunk.is_empty() {
+ if let Some(match_range) = matcher.push(chunk, None) {
+ let anchor_range = buffer.read_with(cx, |buffer, _cx| {
+ buffer.anchor_range_between(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);
+ });
+ }
+ }
+ }
+ }
- // Apply newly-complete edits
- while edit_state.applied_count() < completed_count {
- let edit_index = edit_state.applied_count();
- let partial_edit = &edits[edit_index];
+ ToolEditEvent::OldTextChunk {
+ edit_index,
+ chunk,
+ done: true,
+ } => {
+ pipeline.ensure_resolving_old_text(*edit_index, buffer, cx);
+
+ let EditPipelineEntry::ResolvingOldText { matcher } =
+ &mut pipeline.edits[*edit_index]
+ else {
+ continue;
+ };
- let old_text = partial_edit.old_text.clone().ok_or_else(|| {
- StreamingEditFileToolOutput::Error {
- error: format!("Edit at index {} is missing old_text.", edit_index),
- }
- })?;
- let new_text = partial_edit.new_text.clone().unwrap_or_default();
+ if !chunk.is_empty() {
+ matcher.push(chunk, None);
+ }
+ let matches = matcher.finish();
+
+ if matches.is_empty() {
+ return Err(StreamingEditFileToolOutput::error(format!(
+ "Could not find matching text for edit at index {}. \
+ The old_text did not match any content in the file. \
+ Please read the file again to get the current content.",
+ edit_index,
+ )));
+ }
+ if matches.len() > 1 {
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+ let lines = matches
+ .iter()
+ .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
+ .collect::<Vec<_>>()
+ .join(", ");
+ return Err(StreamingEditFileToolOutput::error(format!(
+ "Edit {} matched multiple locations in the file at lines: {}. \
+ Please provide more context in old_text to uniquely \
+ identify the location.",
+ edit_index, lines
+ )));
+ }
- edit_state.in_progress_matcher = None;
- edit_state.last_old_text_len = 0;
+ let range = matches.into_iter().next().expect("checked len above");
- let edit_op = EditOperation {
- old_text: old_text.clone(),
- new_text: new_text.clone(),
- };
+ let anchor_range = buffer
+ .read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
+ diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
+
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
- let action_log = tool
- .thread
- .read_with(cx, |thread, _cx| thread.action_log().clone())
- .ok();
+ 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 EditPipelineEntry::ResolvingOldText { matcher } =
+ &pipeline.edits[*edit_index]
+ else {
+ continue;
+ };
+ let buffer_indent =
+ snapshot.line_indent_for_row(snapshot.offset_to_point(range.start).row);
+ let query_indent = query_first_line_indent(matcher.query_lines());
+ let indent_delta = compute_indent_delta(buffer_indent, query_indent);
+
+ let old_text_in_buffer =
+ snapshot.text_for_range(range.clone()).collect::<String>();
+
+ let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
+ pipeline.edits[*edit_index] = EditPipelineEntry::StreamingNewText {
+ streaming_diff: StreamingDiff::new(old_text_in_buffer),
+ edit_cursor: range.start,
+ reindenter: Reindenter::new(indent_delta),
+ original_snapshot: text_snapshot,
+ };
- // On the first edit, mark the buffer as read
- if edit_state.applied_count() == 0 {
- if let Some(action_log) = &action_log {
- action_log.update(cx, |log, cx| {
- log.buffer_read(buffer.clone(), cx);
+ cx.update(|cx| {
+ let position = buffer.read(cx).anchor_before(range.end);
+ tool.set_agent_location(buffer.downgrade(), position, cx);
});
}
- }
- let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
-
- let (range, new_text) =
- match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) {
- Ok(resolved) => resolved,
- Err(EditResolveError::NotFound) => {
- return Err(StreamingEditFileToolOutput::Error {
- error: format!(
- "Could not find matching text for edit at index {}. \
- The old_text did not match any content in the file. \
- Please read the file again to get the current content.",
- edit_index
- ),
- });
+ ToolEditEvent::NewTextChunk {
+ edit_index,
+ chunk,
+ done: false,
+ } => {
+ if *edit_index >= pipeline.edits.len() {
+ continue;
}
- Err(EditResolveError::Ambiguous(ranges)) => {
- let lines = ranges
- .iter()
- .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
- .collect::<Vec<_>>()
- .join(", ");
- return Err(StreamingEditFileToolOutput::Error {
- error: format!(
- "Edit {} matched multiple locations in the file at lines: {}. \
- Please provide more context in old_text to uniquely \
- identify the location.",
- edit_index, lines
- ),
- });
+ let EditPipelineEntry::StreamingNewText {
+ streaming_diff,
+ edit_cursor,
+ reindenter,
+ original_snapshot,
+ ..
+ } = &mut pipeline.edits[*edit_index]
+ else {
+ continue;
+ };
+
+ let reindented = reindenter.push(chunk);
+ if reindented.is_empty() {
+ continue;
}
- };
- for previous_range in &edit_state.applied_ranges {
- let previous_start = previous_range.start.to_offset(&snapshot);
- let previous_end = previous_range.end.to_offset(&snapshot);
- if range.start < previous_end && previous_start < range.end {
- let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1;
- let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1;
- let later_start_line = snapshot.offset_to_point(range.start).row + 1;
- let later_end_line = snapshot.offset_to_point(range.end).row + 1;
- return Err(StreamingEditFileToolOutput::Error {
- error: format!(
- "Conflicting edit ranges detected: lines {}-{} \
- conflicts with lines {}-{}. Conflicting edit \
- ranges are not allowed, as they would overwrite \
- each other.",
- earlier_start_line, earlier_end_line, later_start_line, later_end_line,
- ),
+ let char_ops = streaming_diff.push_new(&reindented);
+ Self::apply_char_operations(
+ &char_ops,
+ buffer,
+ original_snapshot,
+ edit_cursor,
+ cx,
+ );
+
+ let position = original_snapshot.anchor_before(*edit_cursor);
+ cx.update(|cx| {
+ tool.set_agent_location(buffer.downgrade(), position, cx);
});
+
+ let action_log = tool
+ .thread
+ .read_with(cx, |thread, _cx| thread.action_log().clone())
+ .ok();
+ if let Some(action_log) = action_log {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ });
+ }
}
- }
- let anchor_range =
- buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone()));
- edit_state.applied_ranges.push(anchor_range);
+ ToolEditEvent::NewTextChunk {
+ edit_index,
+ chunk,
+ done: true,
+ } => {
+ if *edit_index >= pipeline.edits.len() {
+ continue;
+ }
- 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 EditPipelineEntry::StreamingNewText {
+ mut streaming_diff,
+ mut edit_cursor,
+ mut reindenter,
+ original_snapshot,
+ } = std::mem::replace(
+ &mut pipeline.edits[*edit_index],
+ EditPipelineEntry::Done,
+ )
+ else {
+ continue;
+ };
- if let Some(action_log) = action_log {
- cx.update(|cx| {
- buffer.update(cx, |buffer, cx| {
- buffer.edit([(range, new_text.as_str())], None, cx);
- });
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
- });
- }
- }
+ // Flush any remaining reindent buffer + final chunk.
+ let mut final_text = reindenter.push(chunk);
+ final_text.push_str(&reindenter.finish());
- // Feed the in-progress last edit's old_text to the matcher for live preview
- if let Some(partial_edit) = edits.last() {
- if let Some(old_text) = &partial_edit.old_text {
- let old_text_len = old_text.len();
- if old_text_len > edit_state.last_old_text_len {
- let new_chunk = &old_text[edit_state.last_old_text_len..];
+ if !final_text.is_empty() {
+ let char_ops = streaming_diff.push_new(&final_text);
+ Self::apply_char_operations(
+ &char_ops,
+ buffer,
+ &original_snapshot,
+ &mut edit_cursor,
+ cx,
+ );
+ }
+
+ let remaining_ops = streaming_diff.finish();
+ Self::apply_char_operations(
+ &remaining_ops,
+ buffer,
+ &original_snapshot,
+ &mut edit_cursor,
+ cx,
+ );
- let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| {
- let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
- StreamingFuzzyMatcher::new(snapshot)
+ let position = original_snapshot.anchor_before(edit_cursor);
+ cx.update(|cx| {
+ tool.set_agent_location(buffer.downgrade(), position, cx);
});
- if let Some(match_range) = matcher.push(new_chunk, None) {
- let anchor_range = buffer.read_with(cx, |buffer, _cx| {
- buffer.anchor_range_between(match_range.clone())
+ let action_log = tool
+ .thread
+ .read_with(cx, |thread, _cx| thread.action_log().clone())
+ .ok();
+ if let Some(action_log) = action_log {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
});
- diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
}
-
- edit_state.last_old_text_len = old_text_len;
}
}
}
-
Ok(())
}
+
+ fn apply_char_operations(
+ ops: &[CharOperation],
+ buffer: &Entity<Buffer>,
+ snapshot: &text::BufferSnapshot,
+ edit_cursor: &mut usize,
+ cx: &mut AsyncApp,
+ ) {
+ for op in ops {
+ match op {
+ CharOperation::Insert { text } => {
+ let anchor = snapshot.anchor_after(*edit_cursor);
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(anchor..anchor, text.as_str())], None, cx);
+ });
+ });
+ }
+ CharOperation::Delete { bytes } => {
+ let delete_end = *edit_cursor + bytes;
+ let anchor_range = snapshot.anchor_range_around(*edit_cursor..delete_end);
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(anchor_range, "")], None, cx);
+ });
+ });
+ *edit_cursor = delete_end;
+ }
+ CharOperation::Keep { bytes } => {
+ *edit_cursor += bytes;
+ }
+ }
+ }
+ }
}
fn ensure_buffer_saved(
@@ -0,0 +1,941 @@
+use smallvec::SmallVec;
+
+use crate::{Edit, PartialEdit};
+
+/// Events emitted by `ToolEditParser` as tool call input streams in.
+#[derive(Debug, PartialEq, Eq)]
+pub enum ToolEditEvent {
+ /// A chunk of `old_text` for an edit operation.
+ OldTextChunk {
+ edit_index: usize,
+ chunk: String,
+ done: bool,
+ },
+ /// A chunk of `new_text` for an edit operation.
+ NewTextChunk {
+ edit_index: usize,
+ chunk: String,
+ done: bool,
+ },
+ /// A chunk of content for write/overwrite mode.
+ ContentChunk { chunk: String },
+}
+
+/// Tracks the streaming state of a single edit to detect deltas.
+#[derive(Default, Debug)]
+struct EditStreamState {
+ old_text_emitted_len: usize,
+ old_text_done: bool,
+ new_text_emitted_len: usize,
+ new_text_done: bool,
+}
+
+/// Converts incrementally-growing tool call JSON into a stream of chunk events.
+///
+/// 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.
+///
+/// Because partial JSON comes through a fixer (`partial-json-fixer`) that
+/// closes incomplete escape sequences, a string can temporarily contain wrong
+/// trailing characters (e.g. a literal `\` instead of `\n`). We handle this
+/// by holding back trailing backslash characters in non-finalized chunks: if
+/// a partial string ends with `\` (0x5C), that byte is not emitted until the
+/// next partial confirms or corrects it. This avoids feeding corrupted bytes
+/// to downstream consumers.
+#[derive(Default, Debug)]
+pub struct ToolEditParser {
+ edit_states: Vec<EditStreamState>,
+ content_emitted_len: usize,
+}
+
+impl ToolEditParser {
+ /// 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]> {
+ let mut events = SmallVec::new();
+
+ 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) {
+ events.extend(previous);
+ }
+ self.edit_states.push(EditStreamState::default());
+ }
+
+ let state = &mut self.edit_states[index];
+
+ // 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.
+ let start = state.old_text_emitted_len.min(old_text.len());
+ let chunk = old_text[start..].to_string();
+ state.old_text_done = true;
+ state.old_text_emitted_len = old_text.len();
+ events.push(ToolEditEvent::OldTextChunk {
+ edit_index: index,
+ chunk,
+ done: true,
+ });
+ } else {
+ let safe_end = safe_emit_end(old_text);
+ 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 {
+ edit_index: index,
+ chunk,
+ done: false,
+ });
+ }
+ }
+ }
+
+ // Process new_text changes.
+ if let Some(new_text) = &partial.new_text
+ && !state.new_text_done
+ {
+ let safe_end = safe_emit_end(new_text);
+ 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 {
+ edit_index: index,
+ chunk,
+ done: false,
+ });
+ }
+ }
+ }
+
+ events
+ }
+
+ /// Push new content and return any events.
+ ///
+ /// 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]> {
+ 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
+ }
+
+ /// Finalize all edits with the complete input. This emits `done: true`
+ /// events for any in-progress old_text or new_text that hasn't been
+ /// finalized yet.
+ ///
+ /// `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]> {
+ let mut events = SmallVec::new();
+
+ 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) {
+ events.extend(previous);
+ }
+ self.edit_states.push(EditStreamState::default());
+ }
+
+ let state = &mut self.edit_states[index];
+
+ if !state.old_text_done {
+ let start = state.old_text_emitted_len.min(edit.old_text.len());
+ let 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 {
+ edit_index: index,
+ chunk,
+ done: true,
+ });
+ }
+
+ if !state.new_text_done {
+ let start = state.new_text_emitted_len.min(edit.new_text.len());
+ let 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 {
+ edit_index: index,
+ chunk,
+ done: true,
+ });
+ }
+ }
+
+ events
+ }
+
+ /// Finalize content with the complete input.
+ pub fn finalize_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 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
+ }
+
+ /// 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]>> {
+ if new_index == 0 || self.edit_states.is_empty() {
+ return None;
+ }
+
+ let previous_index = new_index - 1;
+ if previous_index >= self.edit_states.len() {
+ return None;
+ }
+
+ let state = &mut self.edit_states[previous_index];
+ let mut events = SmallVec::new();
+
+ // 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 {
+ edit_index: previous_index,
+ chunk: String::new(),
+ done: true,
+ });
+ }
+
+ // 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 {
+ edit_index: previous_index,
+ chunk: String::new(),
+ done: true,
+ });
+ }
+
+ Some(events)
+ }
+}
+
+/// Returns the byte position up to which it is safe to emit from a partial
+/// string. If the string ends with a backslash (`\`, 0x5C), that byte is
+/// held back because it may be an artifact of the partial JSON fixer closing
+/// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
+/// The next partial will reveal the correct character.
+fn safe_emit_end(text: &str) -> usize {
+ if text.as_bytes().last() == Some(&b'\\') {
+ text.len() - 1
+ } else {
+ text.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_single_edit_streamed_incrementally() {
+ let mut parser = ToolEditParser::default();
+
+ // old_text arrives in chunks: "hell" → "hello w" → "hello world"
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hell".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "hell".into(),
+ done: false,
+ }]
+ );
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello w".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "o w".into(),
+ done: false,
+ }]
+ );
+
+ // new_text appears → old_text finalizes
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello world".into()),
+ new_text: Some("good".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "orld".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "good".into(),
+ done: false,
+ },
+ ]
+ );
+
+ // new_text grows
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello world".into()),
+ new_text: Some("goodbye world".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "bye world".into(),
+ done: false,
+ }]
+ );
+
+ // Finalize
+ let events = parser.finalize_edits(&[Edit {
+ old_text: "hello world".into(),
+ new_text: "goodbye world".into(),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "".into(),
+ done: true,
+ }]
+ );
+ }
+
+ #[test]
+ fn test_multiple_edits_sequential() {
+ let mut parser = ToolEditParser::default();
+
+ // First edit streams in
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("first old".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "first old".into(),
+ done: false,
+ }]
+ );
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("first old".into()),
+ new_text: Some("first new".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "first new".into(),
+ done: false,
+ },
+ ]
+ );
+
+ // Second edit appears → first edit's new_text is finalized
+ let events = parser.push_edits(&[
+ PartialEdit {
+ old_text: Some("first old".into()),
+ new_text: Some("first new".into()),
+ },
+ PartialEdit {
+ old_text: Some("second".into()),
+ new_text: None,
+ },
+ ]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "".into(),
+ done: true,
+ },
+ ToolEditEvent::OldTextChunk {
+ edit_index: 1,
+ chunk: "second".into(),
+ done: false,
+ },
+ ]
+ );
+
+ // Finalize everything
+ let events = parser.finalize_edits(&[
+ Edit {
+ old_text: "first old".into(),
+ new_text: "first new".into(),
+ },
+ Edit {
+ old_text: "second old".into(),
+ new_text: "second new".into(),
+ },
+ ]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 1,
+ chunk: " old".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 1,
+ chunk: "second new".into(),
+ done: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_content_streamed_incrementally() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.push_content("hello");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk {
+ chunk: "hello".into(),
+ }]
+ );
+
+ let events = parser.push_content("hello world");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk {
+ chunk: " world".into(),
+ }]
+ );
+
+ // No change
+ let events = parser.push_content("hello world");
+ assert!(events.is_empty());
+
+ let events = parser.push_content("hello world!");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk { chunk: "!".into() }]
+ );
+
+ // Finalize with no additional content
+ let events = parser.finalize_content("hello world!");
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_finalize_content_with_remaining() {
+ let mut parser = ToolEditParser::default();
+
+ parser.push_content("partial");
+ let events = parser.finalize_content("partial content here");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk {
+ chunk: " content here".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn test_content_trailing_backslash_held_back() {
+ let mut parser = ToolEditParser::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 {
+ chunk: "hello,".into(),
+ }]
+ );
+
+ // Next partial corrects the escape to an actual newline.
+ // The held-back byte was wrong; the correct newline is emitted.
+ let events = parser.push_content("hello,\n");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
+ );
+
+ // Normal growth.
+ let events = parser.push_content("hello,\nworld");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk {
+ chunk: "world".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn test_content_finalize_with_trailing_backslash() {
+ let mut parser = ToolEditParser::default();
+
+ // Stream a partial with a fixer-corrupted trailing backslash.
+ // The backslash is held back.
+ parser.push_content("abc\\");
+
+ // Finalize reveals the correct character.
+ let events = parser.finalize_content("abc\n");
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
+ );
+ }
+
+ #[test]
+ fn test_no_partials_direct_finalize() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.finalize_edits(&[Edit {
+ old_text: "old".into(),
+ new_text: "new".into(),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "old".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "new".into(),
+ done: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_no_partials_direct_finalize_multiple() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.finalize_edits(&[
+ Edit {
+ old_text: "first old".into(),
+ new_text: "first new".into(),
+ },
+ Edit {
+ old_text: "second old".into(),
+ new_text: "second new".into(),
+ },
+ ]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "first old".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "first new".into(),
+ done: true,
+ },
+ ToolEditEvent::OldTextChunk {
+ edit_index: 1,
+ chunk: "second old".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 1,
+ chunk: "second new".into(),
+ done: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_old_text_no_growth() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("same".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "same".into(),
+ done: false,
+ }]
+ );
+
+ // Same old_text, no new_text → no events
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("same".into()),
+ new_text: None,
+ }]);
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_old_text_none_then_appears() {
+ let mut parser = ToolEditParser::default();
+
+ // Edit exists but old_text is None (field hasn't arrived yet)
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: None,
+ new_text: None,
+ }]);
+ assert!(events.is_empty());
+
+ // old_text appears
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("text".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "text".into(),
+ done: false,
+ }]
+ );
+ }
+
+ #[test]
+ fn test_empty_old_text_with_new_text() {
+ let mut parser = ToolEditParser::default();
+
+ // old_text is empty, new_text appears immediately
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("".into()),
+ new_text: Some("inserted".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "inserted".into(),
+ done: false,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_three_edits_streamed() {
+ let mut parser = ToolEditParser::default();
+
+ // Stream first edit
+ parser.push_edits(&[PartialEdit {
+ old_text: Some("a".into()),
+ new_text: Some("A".into()),
+ }]);
+
+ // Second edit appears
+ parser.push_edits(&[
+ PartialEdit {
+ old_text: Some("a".into()),
+ new_text: Some("A".into()),
+ },
+ PartialEdit {
+ old_text: Some("b".into()),
+ new_text: Some("B".into()),
+ },
+ ]);
+
+ // Third edit appears
+ let events = parser.push_edits(&[
+ PartialEdit {
+ old_text: Some("a".into()),
+ new_text: Some("A".into()),
+ },
+ PartialEdit {
+ old_text: Some("b".into()),
+ new_text: Some("B".into()),
+ },
+ PartialEdit {
+ old_text: Some("c".into()),
+ new_text: None,
+ },
+ ]);
+
+ // Should finalize edit 1 (index=1) and start edit 2 (index=2)
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::NewTextChunk {
+ edit_index: 1,
+ chunk: "".into(),
+ done: true,
+ },
+ ToolEditEvent::OldTextChunk {
+ edit_index: 2,
+ chunk: "c".into(),
+ done: false,
+ },
+ ]
+ );
+
+ // Finalize
+ let events = parser.finalize_edits(&[
+ Edit {
+ old_text: "a".into(),
+ new_text: "A".into(),
+ },
+ Edit {
+ old_text: "b".into(),
+ new_text: "B".into(),
+ },
+ Edit {
+ old_text: "c".into(),
+ new_text: "C".into(),
+ },
+ ]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 2,
+ chunk: "".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 2,
+ chunk: "C".into(),
+ done: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_finalize_with_unseen_old_text() {
+ let mut parser = ToolEditParser::default();
+
+ // Only saw partial old_text, never saw new_text in partials
+ parser.push_edits(&[PartialEdit {
+ old_text: Some("partial".into()),
+ new_text: None,
+ }]);
+
+ let events = parser.finalize_edits(&[Edit {
+ old_text: "partial old text".into(),
+ new_text: "replacement".into(),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: " old text".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "replacement".into(),
+ done: true,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_finalize_with_partially_seen_new_text() {
+ let mut parser = ToolEditParser::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(),
+ &[ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: " new text".into(),
+ done: true,
+ }]
+ );
+ }
+
+ #[test]
+ fn test_repeated_pushes_with_no_change() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("stable".into()),
+ new_text: Some("also stable".into()),
+ }]);
+ assert_eq!(events.len(), 2); // old done + new chunk
+
+ // Push the exact same data again
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("stable".into()),
+ new_text: Some("also stable".into()),
+ }]);
+ assert!(events.is_empty());
+
+ // And again
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("stable".into()),
+ new_text: Some("also stable".into()),
+ }]);
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_old_text_trailing_backslash_held_back() {
+ let mut parser = ToolEditParser::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
+ // back the trailing backslash instead of emitting it.
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello,\\".into()), // fixer closed incomplete \n as \\
+ new_text: None,
+ }]);
+ // The trailing `\` is held back — only "hello," is emitted.
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "hello,".into(),
+ done: false,
+ }]
+ );
+
+ // Next partial: the fixer corrects the escape to \n.
+ // The held-back byte was wrong, but we never emitted it. Now the
+ // correct newline at that position is emitted normally.
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello,\n".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "\n".into(),
+ done: false,
+ }]
+ );
+
+ // Continue normally.
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("hello,\nworld".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "world".into(),
+ done: false,
+ }]
+ );
+ }
+
+ #[test]
+ fn test_multiline_old_and_new_text() {
+ let mut parser = ToolEditParser::default();
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("line1\nline2".into()),
+ new_text: None,
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "line1\nline2".into(),
+ done: false,
+ }]
+ );
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("line1\nline2\nline3".into()),
+ new_text: Some("LINE1\n".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[
+ ToolEditEvent::OldTextChunk {
+ edit_index: 0,
+ chunk: "\nline3".into(),
+ done: true,
+ },
+ ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "LINE1\n".into(),
+ done: false,
+ },
+ ]
+ );
+
+ let events = parser.push_edits(&[PartialEdit {
+ old_text: Some("line1\nline2\nline3".into()),
+ new_text: Some("LINE1\nLINE2\nLINE3".into()),
+ }]);
+ assert_eq!(
+ events.as_slice(),
+ &[ToolEditEvent::NewTextChunk {
+ edit_index: 0,
+ chunk: "LINE2\nLINE3".into(),
+ done: false,
+ }]
+ );
+ }
+}