| @@ -1,13 +1,17 @@ |
| 1 | 1 | use super::edit_file_tool::EditFileTool; |
| 2 | 2 | use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; |
| 3 | 3 | use super::save_file_tool::SaveFileTool; |
| 4 | +use super::tool_edit_parser::{ToolEditEvent, ToolEditParser}; |
| 4 | 5 | use crate::{ |
| 5 | 6 | AgentTool, Thread, ToolCallEventStream, ToolInput, |
| 6 | | − edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher, |
| 7 | + edit_agent::{ |
| 8 | + reindent::{Reindenter, compute_indent_delta}, |
| 9 | + streaming_fuzzy_matcher::StreamingFuzzyMatcher, |
| 10 | + }, |
| 7 | 11 | }; |
| 8 | 12 | use acp_thread::Diff; |
| 9 | 13 | use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; |
| 10 | | −use anyhow::{Context as _, Result, anyhow}; |
| 14 | +use anyhow::{Context as _, Result}; |
| 11 | 15 | use collections::HashSet; |
| 12 | 16 | use futures::FutureExt as _; |
| 13 | 17 | use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; |
| @@ -15,16 +19,15 @@ use language::language_settings::{self, FormatOnSave}; · 1 unmodified line |
| 15 | 19 | use language::{Buffer, LanguageRegistry}; |
| 16 | 20 | use language_model::LanguageModelToolResultContent; |
| 17 | 21 | use project::lsp_store::{FormatTrigger, LspFormatTarget}; |
| 18 | | −use project::{Project, ProjectPath}; |
| 22 | +use project::{AgentLocation, Project, ProjectPath}; |
| 19 | 23 | use schemars::JsonSchema; |
| 20 | 24 | use serde::{Deserialize, Serialize}; |
| 21 | | −use std::ops::Range; |
| 22 | 25 | use std::path::PathBuf; |
| 23 | 26 | use std::sync::Arc; |
| 24 | | −use text::{BufferSnapshot, ToOffset as _}; |
| 27 | +use streaming_diff::{CharOperation, StreamingDiff}; |
| 25 | 28 | use ui::SharedString; |
| 26 | 29 | use util::rel_path::RelPath; |
| 27 | | −use util::{Deferred, ResultExt, debug_panic}; |
| 30 | +use util::{Deferred, ResultExt}; |
| 28 | 31 | |
| 29 | 32 | const DEFAULT_UI_TEXT: &str = "Editing file"; |
| 30 | 33 | |
| @@ -70,14 +73,13 @@ pub struct StreamingEditFileToolInput { · 39 unmodified lines |
| 70 | 73 | pub path: String, |
| 71 | 74 | |
| 72 | 75 | /// The mode of operation on the file. Possible values: |
| 73 | | − /// - 'create': Create a new file if it doesn't exist. Requires 'content' field. |
| 74 | | − /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field. |
| 76 | + /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field. |
| 75 | 77 | /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. |
| 76 | 78 | /// |
| 77 | 79 | /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. |
| 78 | 80 | pub mode: StreamingEditFileMode, |
| 79 | 81 | |
| 80 | | − /// The complete content for the new file (required for 'create' and 'overwrite' modes). |
| 82 | + /// The complete content for the new file (required for 'write' mode). |
| 81 | 83 | /// This field should contain the entire file content. |
| 82 | 84 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 83 | 85 | pub content: Option<String>, |
| @@ -85,23 +87,22 @@ pub struct StreamingEditFileToolInput { · 1 unmodified line |
| 85 | 87 | /// List of edit operations to apply sequentially (required for 'edit' mode). |
| 86 | 88 | /// Each edit finds `old_text` in the file and replaces it with `new_text`. |
| 87 | 89 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 88 | | − pub edits: Option<Vec<EditOperation>>, |
| 90 | + pub edits: Option<Vec<Edit>>, |
| 89 | 91 | } |
| 90 | 92 | |
| 91 | 93 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] |
| 92 | 94 | #[serde(rename_all = "snake_case")] |
| 93 | 95 | pub enum StreamingEditFileMode { |
| 94 | | − /// Create a new file if it doesn't exist |
| 95 | | − Create, |
| 96 | | − /// Replace the entire contents of an existing file |
| 97 | | − Overwrite, |
| 96 | + /// Overwrite the file with new content (replacing any existing content). |
| 97 | + /// If the file does not exist, it will be created. |
| 98 | + Write, |
| 98 | 99 | /// Make granular edits to an existing file |
| 99 | 100 | Edit, |
| 100 | 101 | } |
| 101 | 102 | |
| 102 | 103 | /// A single edit operation that replaces old text with new text |
| 103 | 104 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] |
| 104 | | −pub struct EditOperation { |
| 105 | +pub struct Edit { |
| 105 | 106 | /// The exact text to find in the file. This will be matched using fuzzy matching |
| 106 | 107 | /// to handle minor differences in whitespace or formatting. |
| 107 | 108 | pub old_text: String, |
| @@ -118,271 +119,328 @@ struct StreamingEditFileToolPartialInput { · 10 unmodified lines |
| 118 | 119 | #[serde(default)] |
| 119 | 120 | mode: Option<StreamingEditFileMode>, |
| 120 | 121 | #[serde(default)] |
| 121 | | − #[allow(dead_code)] |
| 122 | 122 | content: Option<String>, |
| 123 | 123 | #[serde(default)] |
| 124 | | − edits: Option<Vec<PartialEditOperation>>, |
| 124 | + edits: Option<Vec<PartialEdit>>, |
| 125 | 125 | } |
| 126 | 126 | |
| 127 | 127 | #[derive(Default, Debug, Deserialize)] |
| 128 | | −struct PartialEditOperation { |
| 128 | +pub struct PartialEdit { |
| 129 | 129 | #[serde(default)] |
| 130 | | − old_text: Option<String>, |
| 130 | + pub old_text: Option<String>, |
| 131 | 131 | #[serde(default)] |
| 132 | | − new_text: Option<String>, |
| 132 | + pub new_text: Option<String>, |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | | −enum StreamingEditState { |
| 136 | | − Idle, |
| 137 | | − BufferResolved { |
| 138 | | − abs_path: PathBuf, |
| 139 | | − buffer: Entity<Buffer>, |
| 135 | +#[derive(Debug, Serialize, Deserialize)] |
| 136 | +#[serde(untagged)] |
| 137 | +pub enum StreamingEditFileToolOutput { |
| 138 | + Success { |
| 139 | + #[serde(alias = "original_path")] |
| 140 | + input_path: PathBuf, |
| 141 | + new_text: String, |
| 140 | 142 | old_text: Arc<String>, |
| 141 | | − diff: Entity<Diff>, |
| 142 | | − mode: StreamingEditFileMode, |
| 143 | | − last_content_len: usize, |
| 144 | | − edit_state: IncrementalEditState, |
| 145 | | − _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>, |
| 143 | + #[serde(default)] |
| 144 | + diff: String, |
| 145 | + }, |
| 146 | + Error { |
| 147 | + error: String, |
| 146 | 148 | }, |
| 147 | 149 | } |
| 148 | 150 | |
| 149 | | −#[derive(Default)] |
| 150 | | −struct IncrementalEditState { |
| 151 | | − in_progress_matcher: Option<StreamingFuzzyMatcher>, |
| 152 | | − last_old_text_len: usize, |
| 153 | | − applied_ranges: Vec<Range<text::Anchor>>, |
| 151 | +impl StreamingEditFileToolOutput { |
| 152 | + pub fn error(error: impl Into<String>) -> Self { |
| 153 | + Self::Error { |
| 154 | + error: error.into(), |
| 155 | + } |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +impl std::fmt::Display for StreamingEditFileToolOutput { |
| 160 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 161 | + match self { |
| 162 | + StreamingEditFileToolOutput::Success { |
| 163 | + diff, input_path, .. |
| 164 | + } => { |
| 165 | + if diff.is_empty() { |
| 166 | + write!(f, "No edits were made.") |
| 167 | + } else { |
| 168 | + write!( |
| 169 | + f, |
| 170 | + "Edited {}:\n\n```diff\n{diff}\n```", |
| 171 | + input_path.display() |
| 172 | + ) |
| 173 | + } |
| 174 | + } |
| 175 | + StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"), |
| 176 | + } |
| 177 | + } |
| 154 | 178 | } |
| 155 | 179 | |
| 156 | | −impl IncrementalEditState { |
| 157 | | − fn applied_count(&self) -> usize { |
| 158 | | − self.applied_ranges.len() |
| 180 | +impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent { |
| 181 | + fn from(output: StreamingEditFileToolOutput) -> Self { |
| 182 | + output.to_string().into() |
| 159 | 183 | } |
| 160 | 184 | } |
| 161 | 185 | |
| 162 | | −impl StreamingEditState { |
| 163 | | − async fn finalize( |
| 164 | | − &mut self, |
| 165 | | − input: StreamingEditFileToolInput, |
| 166 | | − tool: &StreamingEditFileTool, |
| 186 | +pub struct StreamingEditFileTool { |
| 187 | + thread: WeakEntity<Thread>, |
| 188 | + language_registry: Arc<LanguageRegistry>, |
| 189 | + project: Entity<Project>, |
| 190 | +} |
| 191 | + |
| 192 | +impl StreamingEditFileTool { |
| 193 | + pub fn new( |
| 194 | + project: Entity<Project>, |
| 195 | + thread: WeakEntity<Thread>, |
| 196 | + language_registry: Arc<LanguageRegistry>, |
| 197 | + ) -> Self { |
| 198 | + Self { |
| 199 | + project, |
| 200 | + thread, |
| 201 | + language_registry, |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + fn authorize( |
| 206 | + &self, |
| 207 | + path: &PathBuf, |
| 208 | + description: &str, |
| 167 | 209 | event_stream: &ToolCallEventStream, |
| 168 | | − cx: &mut AsyncApp, |
| 169 | | − ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> { |
| 170 | | − let remaining_edits_start_ix = match self { |
| 171 | | − StreamingEditState::Idle => { |
| 172 | | − *self = Self::transition_to_buffer_resolved( |
| 173 | | − &input.path, |
| 174 | | − &input.display_description, |
| 175 | | − input.mode.clone(), |
| 176 | | − tool, |
| 177 | | − event_stream, |
| 178 | | − cx, |
| 179 | | − ) |
| 180 | | − .await?; |
| 181 | | − 0 |
| 182 | | − } |
| 183 | | − StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(), |
| 184 | | − }; |
| 210 | + cx: &mut App, |
| 211 | + ) -> Task<Result<()>> { |
| 212 | + super::tool_permissions::authorize_file_edit( |
| 213 | + EditFileTool::NAME, |
| 214 | + path, |
| 215 | + description, |
| 216 | + &self.thread, |
| 217 | + event_stream, |
| 218 | + cx, |
| 219 | + ) |
| 220 | + } |
| 185 | 221 | |
| 186 | | − let StreamingEditState::BufferResolved { |
| 187 | | − buffer, |
| 188 | | − old_text, |
| 189 | | − diff, |
| 190 | | − abs_path, |
| 191 | | − .. |
| 192 | | − } = self |
| 193 | | − else { |
| 194 | | − debug_panic!("Invalid state"); |
| 195 | | − return Ok(StreamingEditFileToolOutput::Error { |
| 196 | | − error: "Internal error. Try to apply the edits again".to_string(), |
| 197 | | − }); |
| 198 | | − }; |
| 222 | + fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) { |
| 223 | + self.project.update(cx, |project, cx| { |
| 224 | + project.set_agent_location(Some(AgentLocation { buffer, position }), cx); |
| 225 | + }); |
| 226 | + } |
| 227 | +} |
| 199 | 228 | |
| 200 | | − let result: anyhow::Result<StreamingEditFileToolOutput> = async { |
| 201 | | − let action_log = tool |
| 202 | | − .thread |
| 203 | | − .read_with(cx, |thread, _cx| thread.action_log().clone())?; |
| 229 | +impl AgentTool for StreamingEditFileTool { |
| 230 | + type Input = StreamingEditFileToolInput; |
| 231 | + type Output = StreamingEditFileToolOutput; |
| 204 | 232 | |
| 205 | | − match input.mode { |
| 206 | | − StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => { |
| 207 | | − action_log.update(cx, |log, cx| { |
| 208 | | − log.buffer_created(buffer.clone(), cx); |
| 209 | | − }); |
| 210 | | − let content = input.content.ok_or_else(|| { |
| 211 | | − anyhow!("'content' field is required for create and overwrite modes") |
| 212 | | − })?; |
| 213 | | − cx.update(|cx| { |
| 214 | | − buffer.update(cx, |buffer, cx| { |
| 215 | | − buffer.edit([(0..buffer.len(), content.as_str())], None, cx); |
| 216 | | − }); |
| 217 | | − action_log.update(cx, |log, cx| { |
| 218 | | − log.buffer_edited(buffer.clone(), cx); |
| 219 | | − }); |
| 220 | | − }); |
| 221 | | − } |
| 222 | | − StreamingEditFileMode::Edit => { |
| 223 | | − let edits = input |
| 224 | | − .edits |
| 225 | | − .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?; |
| 226 | | − |
| 227 | | − let remaining_edits = &edits[remaining_edits_start_ix..]; |
| 228 | | − apply_edits( |
| 229 | | − &buffer, |
| 230 | | − &action_log, |
| 231 | | − remaining_edits, |
| 232 | | − &diff, |
| 233 | | − event_stream, |
| 234 | | − &abs_path, |
| 235 | | − cx, |
| 236 | | − )?; |
| 237 | | − } |
| 238 | | − } |
| 233 | + const NAME: &'static str = "streaming_edit_file"; |
| 239 | 234 | |
| 240 | | − let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { |
| 241 | | − let settings = language_settings::language_settings( |
| 242 | | − buffer.language().map(|l| l.name()), |
| 243 | | − buffer.file(), |
| 244 | | − cx, |
| 245 | | − ); |
| 246 | | − settings.format_on_save != FormatOnSave::Off |
| 247 | | − }); |
| 235 | + fn supports_input_streaming() -> bool { |
| 236 | + true |
| 237 | + } |
| 248 | 238 | |
| 249 | | − if format_on_save_enabled { |
| 250 | | − action_log.update(cx, |log, cx| { |
| 251 | | − log.buffer_edited(buffer.clone(), cx); |
| 252 | | − }); |
| 239 | + fn kind() -> acp::ToolKind { |
| 240 | + acp::ToolKind::Edit |
| 241 | + } |
| 253 | 242 | |
| 254 | | − let format_task = tool.project.update(cx, |project, cx| { |
| 255 | | − project.format( |
| 256 | | − HashSet::from_iter([buffer.clone()]), |
| 257 | | − LspFormatTarget::Buffers, |
| 258 | | − false, |
| 259 | | − FormatTrigger::Save, |
| 260 | | − cx, |
| 261 | | − ) |
| 262 | | − }); |
| 263 | | − futures::select! { |
| 264 | | − result = format_task.fuse() => { result.log_err(); }, |
| 265 | | − _ = event_stream.cancelled_by_user().fuse() => { |
| 266 | | − anyhow::bail!("Edit cancelled by user"); |
| 243 | + fn initial_title( |
| 244 | + &self, |
| 245 | + input: Result<Self::Input, serde_json::Value>, |
| 246 | + cx: &mut App, |
| 247 | + ) -> SharedString { |
| 248 | + match input { |
| 249 | + Ok(input) => self |
| 250 | + .project |
| 251 | + .read(cx) |
| 252 | + .find_project_path(&input.path, cx) |
| 253 | + .and_then(|project_path| { |
| 254 | + self.project |
| 255 | + .read(cx) |
| 256 | + .short_full_path_for_project_path(&project_path, cx) |
| 257 | + }) |
| 258 | + .unwrap_or(input.path) |
| 259 | + .into(), |
| 260 | + Err(raw_input) => { |
| 261 | + if let Some(input) = |
| 262 | + serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok() |
| 263 | + { |
| 264 | + let path = input.path.unwrap_or_default(); |
| 265 | + let path = path.trim(); |
| 266 | + if !path.is_empty() { |
| 267 | + return self |
| 268 | + .project |
| 269 | + .read(cx) |
| 270 | + .find_project_path(&path, cx) |
| 271 | + .and_then(|project_path| { |
| 272 | + self.project |
| 273 | + .read(cx) |
| 274 | + .short_full_path_for_project_path(&project_path, cx) |
| 275 | + }) |
| 276 | + .unwrap_or_else(|| path.to_string()) |
| 277 | + .into(); |
| 267 | 278 | } |
| 268 | | − }; |
| 269 | | − } |
| 270 | 279 | |
| 271 | | − let save_task = tool |
| 272 | | − .project |
| 273 | | − .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); |
| 274 | | − futures::select! { |
| 275 | | − result = save_task.fuse() => { result?; }, |
| 276 | | − _ = event_stream.cancelled_by_user().fuse() => { |
| 277 | | − anyhow::bail!("Edit cancelled by user"); |
| 280 | + let description = input.display_description.unwrap_or_default(); |
| 281 | + let description = description.trim(); |
| 282 | + if !description.is_empty() { |
| 283 | + return description.to_string().into(); |
| 284 | + } |
| 278 | 285 | } |
| 279 | | − }; |
| 280 | | − |
| 281 | | − action_log.update(cx, |log, cx| { |
| 282 | | − log.buffer_edited(buffer.clone(), cx); |
| 283 | | − }); |
| 284 | 286 | |
| 285 | | − if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { |
| 286 | | − buffer.file().and_then(|file| file.disk_state().mtime()) |
| 287 | | − }) { |
| 288 | | − tool.thread.update(cx, |thread, _| { |
| 289 | | − thread |
| 290 | | − .file_read_times |
| 291 | | − .insert(abs_path.to_path_buf(), new_mtime); |
| 292 | | − })?; |
| 287 | + DEFAULT_UI_TEXT.into() |
| 293 | 288 | } |
| 289 | + } |
| 290 | + } |
| 294 | 291 | |
| 295 | | − let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 296 | | − let (new_text, unified_diff) = cx |
| 297 | | − .background_spawn({ |
| 298 | | − let new_snapshot = new_snapshot.clone(); |
| 299 | | − let old_text = old_text.clone(); |
| 300 | | − async move { |
| 301 | | − let new_text = new_snapshot.text(); |
| 302 | | − let diff = language::unified_diff(&old_text, &new_text); |
| 303 | | − (new_text, diff) |
| 292 | + fn run( |
| 293 | + self: Arc<Self>, |
| 294 | + mut input: ToolInput<Self::Input>, |
| 295 | + event_stream: ToolCallEventStream, |
| 296 | + cx: &mut App, |
| 297 | + ) -> Task<Result<Self::Output, Self::Output>> { |
| 298 | + cx.spawn(async move |cx: &mut AsyncApp| { |
| 299 | + let mut state: Option<EditSession> = None; |
| 300 | + loop { |
| 301 | + futures::select! { |
| 302 | + partial = input.recv_partial().fuse() => { |
| 303 | + let Some(partial_value) = partial else { break }; |
| 304 | + if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) { |
| 305 | + if state.is_none() && let Some(path_str) = &parsed.path |
| 306 | + && let Some(display_description) = &parsed.display_description |
| 307 | + && let Some(mode) = parsed.mode.clone() { |
| 308 | + state = Some( |
| 309 | + EditSession::new( |
| 310 | + path_str, |
| 311 | + display_description, |
| 312 | + mode, |
| 313 | + &self, |
| 314 | + &event_stream, |
| 315 | + cx, |
| 316 | + ) |
| 317 | + .await?, |
| 318 | + ); |
| 319 | + } |
| 320 | + |
| 321 | + if let Some(state) = &mut state { |
| 322 | + state.process(parsed, &self, &event_stream, cx)?; |
| 323 | + } |
| 324 | + } |
| 304 | 325 | } |
| 305 | | − }) |
| 306 | | − .await; |
| 326 | + _ = event_stream.cancelled_by_user().fuse() => { |
| 327 | + return Err(StreamingEditFileToolOutput::error("Edit cancelled by user")); |
| 328 | + } |
| 329 | + } |
| 330 | + } |
| 331 | + let full_input = |
| 332 | + input |
| 333 | + .recv() |
| 334 | + .await |
| 335 | + .map_err(|e| StreamingEditFileToolOutput::error(format!("Failed to receive tool input: {e}")))?; |
| 307 | 336 | |
| 308 | | − let output = StreamingEditFileToolOutput::Success { |
| 309 | | − input_path: PathBuf::from(input.path), |
| 310 | | − new_text, |
| 311 | | − old_text: old_text.clone(), |
| 312 | | − diff: unified_diff, |
| 337 | + let mut state = if let Some(state) = state { |
| 338 | + state |
| 339 | + } else { |
| 340 | + EditSession::new( |
| 341 | + &full_input.path, |
| 342 | + &full_input.display_description, |
| 343 | + full_input.mode.clone(), |
| 344 | + &self, |
| 345 | + &event_stream, |
| 346 | + cx, |
| 347 | + ) |
| 348 | + .await? |
| 313 | 349 | }; |
| 314 | | − Ok(output) |
| 315 | | − } |
| 316 | | − .await; |
| 317 | | − result.map_err(|e| StreamingEditFileToolOutput::Error { |
| 318 | | − error: e.to_string(), |
| 350 | + state.finalize(full_input, &self, &event_stream, cx).await |
| 319 | 351 | }) |
| 320 | 352 | } |
| 321 | 353 | |
| 322 | | − async fn process( |
| 323 | | − &mut self, |
| 324 | | − partial: StreamingEditFileToolPartialInput, |
| 325 | | − tool: &StreamingEditFileTool, |
| 326 | | − event_stream: &ToolCallEventStream, |
| 327 | | − cx: &mut AsyncApp, |
| 328 | | − ) -> Result<(), StreamingEditFileToolOutput> { |
| 329 | | − match self { |
| 330 | | − Self::Idle => { |
| 331 | | − if let Some(path_str) = partial.path |
| 332 | | − && let Some(display_description) = partial.display_description |
| 333 | | − && let Some(mode) = partial.mode |
| 334 | | − { |
| 335 | | − *self = Self::transition_to_buffer_resolved( |
| 336 | | − &path_str, |
| 337 | | − &display_description, |
| 338 | | − mode, |
| 339 | | − tool, |
| 340 | | − event_stream, |
| 354 | + fn replay( |
| 355 | + &self, |
| 356 | + _input: Self::Input, |
| 357 | + output: Self::Output, |
| 358 | + event_stream: ToolCallEventStream, |
| 359 | + cx: &mut App, |
| 360 | + ) -> Result<()> { |
| 361 | + match output { |
| 362 | + StreamingEditFileToolOutput::Success { |
| 363 | + input_path, |
| 364 | + old_text, |
| 365 | + new_text, |
| 366 | + .. |
| 367 | + } => { |
| 368 | + event_stream.update_diff(cx.new(|cx| { |
| 369 | + Diff::finalized( |
| 370 | + input_path.to_string_lossy().into_owned(), |
| 371 | + Some(old_text.to_string()), |
| 372 | + new_text, |
| 373 | + self.language_registry.clone(), |
| 341 | 374 | cx, |
| 342 | 375 | ) |
| 343 | | − .await?; |
| 344 | | − } |
| 376 | + })); |
| 377 | + Ok(()) |
| 345 | 378 | } |
| 346 | | − Self::BufferResolved { |
| 347 | | − abs_path, |
| 348 | | − buffer, |
| 349 | | − edit_state, |
| 350 | | − diff, |
| 351 | | − mode, |
| 352 | | − last_content_len, |
| 353 | | − .. |
| 354 | | − } => match mode { |
| 355 | | − StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => { |
| 356 | | − if let Some(content) = &partial.content { |
| 357 | | − Self::process_streaming_content( |
| 358 | | − buffer, |
| 359 | | − diff, |
| 360 | | − last_content_len, |
| 361 | | − content, |
| 362 | | − cx, |
| 363 | | − )?; |
| 364 | | − } |
| 365 | | − } |
| 366 | | − StreamingEditFileMode::Edit => { |
| 367 | | − if let Some(edits) = partial.edits { |
| 368 | | − Self::process_streaming_edits( |
| 369 | | − buffer, |
| 370 | | − diff, |
| 371 | | − edit_state, |
| 372 | | − &edits, |
| 373 | | − abs_path, |
| 374 | | − tool, |
| 375 | | − event_stream, |
| 376 | | − cx, |
| 377 | | − )?; |
| 378 | | − } |
| 379 | | − } |
| 380 | | − }, |
| 379 | + StreamingEditFileToolOutput::Error { .. } => Ok(()), |
| 380 | + } |
| 381 | + } |
| 382 | +} |
| 383 | + |
| 384 | +pub struct EditSession { |
| 385 | + abs_path: PathBuf, |
| 386 | + buffer: Entity<Buffer>, |
| 387 | + old_text: Arc<String>, |
| 388 | + diff: Entity<Diff>, |
| 389 | + mode: StreamingEditFileMode, |
| 390 | + parser: ToolEditParser, |
| 391 | + pipeline: EditPipeline, |
| 392 | + _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>, |
| 393 | +} |
| 394 | + |
| 395 | +struct EditPipeline { |
| 396 | + edits: Vec<EditPipelineEntry>, |
| 397 | + content_written: bool, |
| 398 | +} |
| 399 | + |
| 400 | +enum EditPipelineEntry { |
| 401 | + ResolvingOldText { |
| 402 | + matcher: StreamingFuzzyMatcher, |
| 403 | + }, |
| 404 | + StreamingNewText { |
| 405 | + streaming_diff: StreamingDiff, |
| 406 | + edit_cursor: usize, |
| 407 | + reindenter: Reindenter, |
| 408 | + original_snapshot: text::BufferSnapshot, |
| 409 | + }, |
| 410 | + Done, |
| 411 | +} |
| 412 | + |
| 413 | +impl EditPipeline { |
| 414 | + fn new() -> Self { |
| 415 | + Self { |
| 416 | + edits: Vec::new(), |
| 417 | + content_written: false, |
| 381 | 418 | } |
| 382 | | − Ok(()) |
| 383 | 419 | } |
| 384 | 420 | |
| 385 | | − async fn transition_to_buffer_resolved( |
| 421 | + fn ensure_resolving_old_text( |
| 422 | + &mut self, |
| 423 | + edit_index: usize, |
| 424 | + buffer: &Entity<Buffer>, |
| 425 | + cx: &mut AsyncApp, |
| 426 | + ) { |
| 427 | + while self.edits.len() <= edit_index { |
| 428 | + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); |
| 429 | + self.edits.push(EditPipelineEntry::ResolvingOldText { |
| 430 | + matcher: StreamingFuzzyMatcher::new(snapshot), |
| 431 | + }); |
| 432 | + } |
| 433 | + } |
| 434 | +} |
| 435 | + |
| 436 | +/// Compute the `LineIndent` of the first line in a set of query lines. |
| 437 | +fn query_first_line_indent(query_lines: &[String]) -> text::LineIndent { |
| 438 | + let first_line = query_lines.first().map(|s| s.as_str()).unwrap_or(""); |
| 439 | + text::LineIndent::from_iter(first_line.chars()) |
| 440 | +} |
| 441 | + |
| 442 | +impl EditSession { |
| 443 | + async fn new( |
| 386 | 444 | path_str: &str, |
| 387 | 445 | display_description: &str, |
| 388 | 446 | mode: StreamingEditFileMode, |
| @@ -393,15 +451,13 @@ impl StreamingEditState { · 4 unmodified lines |
| 393 | 451 | let path = PathBuf::from(path_str); |
| 394 | 452 | let project_path = cx |
| 395 | 453 | .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx)) |
| 396 | | − .map_err(|e| StreamingEditFileToolOutput::Error { |
| 397 | | − error: e.to_string(), |
| 398 | | − })?; |
| 454 | + .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 399 | 455 | |
| 400 | 456 | let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) |
| 401 | 457 | else { |
| 402 | | − return Err(StreamingEditFileToolOutput::Error { |
| 403 | | − error: format!("File '{path_str}' does not exist"), |
| 404 | | − }); |
| 458 | + return Err(StreamingEditFileToolOutput::error(format!( |
| 459 | + "Worktree at '{path_str}' does not exist" |
| 460 | + ))); |
| 405 | 461 | }; |
| 406 | 462 | |
| 407 | 463 | event_stream.update_fields( |
| @@ -410,17 +466,13 @@ impl StreamingEditState { · 2 unmodified lines |
| 410 | 466 | |
| 411 | 467 | cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) |
| 412 | 468 | .await |
| 413 | | − .map_err(|e| StreamingEditFileToolOutput::Error { |
| 414 | | − error: e.to_string(), |
| 415 | | − })?; |
| 469 | + .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 416 | 470 | |
| 417 | 471 | let buffer = tool |
| 418 | 472 | .project |
| 419 | 473 | .update(cx, |project, cx| project.open_buffer(project_path, cx)) |
| 420 | 474 | .await |
| 421 | | − .map_err(|e| StreamingEditFileToolOutput::Error { |
| 422 | | − error: e.to_string(), |
| 423 | | − })?; |
| 475 | + .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 424 | 476 | |
| 425 | 477 | ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; |
| 426 | 478 | |
| @@ -434,6 +486,14 @@ impl StreamingEditState { · 7 unmodified lines |
| 434 | 486 | } |
| 435 | 487 | }) as Box<dyn FnOnce()>); |
| 436 | 488 | |
| 489 | + tool.thread |
| 490 | + .update(cx, |thread, cx| { |
| 491 | + thread |
| 492 | + .action_log() |
| 493 | + .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)) |
| 494 | + }) |
| 495 | + .ok(); |
| 496 | + |
| 437 | 497 | let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 438 | 498 | let old_text = cx |
| 439 | 499 | .background_spawn({ |
| @@ -442,205 +502,497 @@ impl StreamingEditState { · 2 unmodified lines |
| 442 | 502 | }) |
| 443 | 503 | .await; |
| 444 | 504 | |
| 445 | | − Ok(Self::BufferResolved { |
| 505 | + Ok(Self { |
| 446 | 506 | abs_path, |
| 447 | 507 | buffer, |
| 448 | 508 | old_text, |
| 449 | 509 | diff, |
| 450 | 510 | mode, |
| 451 | | − last_content_len: 0, |
| 452 | | − edit_state: IncrementalEditState::default(), |
| 511 | + parser: ToolEditParser::default(), |
| 512 | + pipeline: EditPipeline::new(), |
| 453 | 513 | _finalize_diff_guard: finalize_diff_guard, |
| 454 | 514 | }) |
| 455 | 515 | } |
| 456 | 516 | |
| 457 | | − fn process_streaming_content( |
| 458 | | − buffer: &Entity<Buffer>, |
| 459 | | − diff: &Entity<Diff>, |
| 460 | | − last_content_len: &mut usize, |
| 461 | | − content: &str, |
| 517 | + async fn finalize( |
| 518 | + &mut self, |
| 519 | + input: StreamingEditFileToolInput, |
| 520 | + tool: &StreamingEditFileTool, |
| 521 | + event_stream: &ToolCallEventStream, |
| 462 | 522 | cx: &mut AsyncApp, |
| 463 | | − ) -> Result<(), StreamingEditFileToolOutput> { |
| 464 | | − let new_len = content.len(); |
| 465 | | − if new_len > *last_content_len { |
| 466 | | − let new_chunk = &content[*last_content_len..]; |
| 467 | | − cx.update(|cx| { |
| 468 | | − buffer.update(cx, |buffer, cx| { |
| 469 | | − // On the first update, replace the entire buffer (handles Overwrite |
| 470 | | − // clearing existing content). For Create the buffer is already empty |
| 471 | | − // so 0..0 is a no-op range prefix. |
| 472 | | − let insert_at = if *last_content_len == 0 { |
| 473 | | − 0..buffer.len() |
| 474 | | − } else { |
| 475 | | − let len = buffer.len(); |
| 476 | | − len..len |
| 477 | | − }; |
| 478 | | − buffer.edit([(insert_at, new_chunk)], None, cx); |
| 523 | + ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> { |
| 524 | + let Self { |
| 525 | + buffer, |
| 526 | + old_text, |
| 527 | + diff, |
| 528 | + abs_path, |
| 529 | + parser, |
| 530 | + pipeline, |
| 531 | + .. |
| 532 | + } = self; |
| 533 | + |
| 534 | + let action_log = tool |
| 535 | + .thread |
| 536 | + .read_with(cx, |thread, _cx| thread.action_log().clone()) |
| 537 | + .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 538 | + |
| 539 | + match input.mode { |
| 540 | + StreamingEditFileMode::Write => { |
| 541 | + action_log.update(cx, |log, cx| { |
| 542 | + log.buffer_created(buffer.clone(), cx); |
| 479 | 543 | }); |
| 544 | + let content = input.content.ok_or_else(|| { |
| 545 | + StreamingEditFileToolOutput::error("'content' field is required for write mode") |
| 546 | + })?; |
| 547 | + |
| 548 | + let events = parser.finalize_content(&content); |
| 549 | + Self::process_events( |
| 550 | + &events, |
| 551 | + buffer, |
| 552 | + diff, |
| 553 | + pipeline, |
| 554 | + abs_path, |
| 555 | + tool, |
| 556 | + event_stream, |
| 557 | + cx, |
| 558 | + )?; |
| 559 | + } |
| 560 | + StreamingEditFileMode::Edit => { |
| 561 | + let edits = input.edits.ok_or_else(|| { |
| 562 | + StreamingEditFileToolOutput::error("'edits' field is required for edit mode") |
| 563 | + })?; |
| 564 | + |
| 565 | + let final_edits = edits |
| 566 | + .into_iter() |
| 567 | + .map(|e| Edit { |
| 568 | + old_text: e.old_text, |
| 569 | + new_text: e.new_text, |
| 570 | + }) |
| 571 | + .collect::<Vec<_>>(); |
| 572 | + let events = parser.finalize_edits(&final_edits); |
| 573 | + Self::process_events( |
| 574 | + &events, |
| 575 | + buffer, |
| 576 | + diff, |
| 577 | + pipeline, |
| 578 | + abs_path, |
| 579 | + tool, |
| 580 | + event_stream, |
| 581 | + cx, |
| 582 | + )?; |
| 583 | + } |
| 584 | + } |
| 585 | + |
| 586 | + let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { |
| 587 | + let settings = language_settings::language_settings( |
| 588 | + buffer.language().map(|l| l.name()), |
| 589 | + buffer.file(), |
| 590 | + cx, |
| 591 | + ); |
| 592 | + settings.format_on_save != FormatOnSave::Off |
| 593 | + }); |
| 594 | + |
| 595 | + if format_on_save_enabled { |
| 596 | + action_log.update(cx, |log, cx| { |
| 597 | + log.buffer_edited(buffer.clone(), cx); |
| 480 | 598 | }); |
| 481 | | − *last_content_len = new_len; |
| 482 | 599 | |
| 483 | | − let anchor_range = buffer.read_with(cx, |buffer, _cx| { |
| 484 | | − buffer.anchor_range_between(0..buffer.len()) |
| 600 | + let format_task = tool.project.update(cx, |project, cx| { |
| 601 | + project.format( |
| 602 | + HashSet::from_iter([buffer.clone()]), |
| 603 | + LspFormatTarget::Buffers, |
| 604 | + false, |
| 605 | + FormatTrigger::Save, |
| 606 | + cx, |
| 607 | + ) |
| 485 | 608 | }); |
| 486 | | − diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); |
| 609 | + futures::select! { |
| 610 | + result = format_task.fuse() => { result.log_err(); }, |
| 611 | + _ = event_stream.cancelled_by_user().fuse() => { |
| 612 | + return Err(StreamingEditFileToolOutput::error("Edit cancelled by user")); |
| 613 | + } |
| 614 | + }; |
| 615 | + } |
| 616 | + |
| 617 | + let save_task = tool |
| 618 | + .project |
| 619 | + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); |
| 620 | + futures::select! { |
| 621 | + result = save_task.fuse() => { result.map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; }, |
| 622 | + _ = event_stream.cancelled_by_user().fuse() => { |
| 623 | + return Err(StreamingEditFileToolOutput::error("Edit cancelled by user")); |
| 624 | + } |
| 625 | + }; |
| 626 | + |
| 627 | + action_log.update(cx, |log, cx| { |
| 628 | + log.buffer_edited(buffer.clone(), cx); |
| 629 | + }); |
| 630 | + |
| 631 | + if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { |
| 632 | + buffer.file().and_then(|file| file.disk_state().mtime()) |
| 633 | + }) { |
| 634 | + tool.thread |
| 635 | + .update(cx, |thread, _| { |
| 636 | + thread |
| 637 | + .file_read_times |
| 638 | + .insert(abs_path.to_path_buf(), new_mtime); |
| 639 | + }) |
| 640 | + .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 641 | + } |
| 642 | + |
| 643 | + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 644 | + let (new_text, unified_diff) = cx |
| 645 | + .background_spawn({ |
| 646 | + let new_snapshot = new_snapshot.clone(); |
| 647 | + let old_text = old_text.clone(); |
| 648 | + async move { |
| 649 | + let new_text = new_snapshot.text(); |
| 650 | + let diff = language::unified_diff(&old_text, &new_text); |
| 651 | + (new_text, diff) |
| 652 | + } |
| 653 | + }) |
| 654 | + .await; |
| 655 | + |
| 656 | + let output = StreamingEditFileToolOutput::Success { |
| 657 | + input_path: PathBuf::from(input.path), |
| 658 | + new_text, |
| 659 | + old_text: old_text.clone(), |
| 660 | + diff: unified_diff, |
| 661 | + }; |
| 662 | + Ok(output) |
| 663 | + } |
| 664 | + |
| 665 | + fn process( |
| 666 | + &mut self, |
| 667 | + partial: StreamingEditFileToolPartialInput, |
| 668 | + tool: &StreamingEditFileTool, |
| 669 | + event_stream: &ToolCallEventStream, |
| 670 | + cx: &mut AsyncApp, |
| 671 | + ) -> Result<(), StreamingEditFileToolOutput> { |
| 672 | + match &self.mode { |
| 673 | + StreamingEditFileMode::Write => { |
| 674 | + if let Some(content) = &partial.content { |
| 675 | + let events = self.parser.push_content(content); |
| 676 | + Self::process_events( |
| 677 | + &events, |
| 678 | + &self.buffer, |
| 679 | + &self.diff, |
| 680 | + &mut self.pipeline, |
| 681 | + &self.abs_path, |
| 682 | + tool, |
| 683 | + event_stream, |
| 684 | + cx, |
| 685 | + )?; |
| 686 | + } |
| 687 | + } |
| 688 | + StreamingEditFileMode::Edit => { |
| 689 | + if let Some(edits) = partial.edits { |
| 690 | + let events = self.parser.push_edits(&edits); |
| 691 | + Self::process_events( |
| 692 | + &events, |
| 693 | + &self.buffer, |
| 694 | + &self.diff, |
| 695 | + &mut self.pipeline, |
| 696 | + &self.abs_path, |
| 697 | + tool, |
| 698 | + event_stream, |
| 699 | + cx, |
| 700 | + )?; |
| 701 | + } |
| 702 | + } |
| 487 | 703 | } |
| 488 | 704 | Ok(()) |
| 489 | 705 | } |
| 490 | 706 | |
| 491 | | − fn process_streaming_edits( |
| 707 | + fn process_events( |
| 708 | + events: &[ToolEditEvent], |
| 492 | 709 | buffer: &Entity<Buffer>, |
| 493 | 710 | diff: &Entity<Diff>, |
| 494 | | − edit_state: &mut IncrementalEditState, |
| 495 | | − edits: &[PartialEditOperation], |
| 711 | + pipeline: &mut EditPipeline, |
| 496 | 712 | abs_path: &PathBuf, |
| 497 | 713 | tool: &StreamingEditFileTool, |
| 498 | 714 | event_stream: &ToolCallEventStream, |
| 499 | 715 | cx: &mut AsyncApp, |
| 500 | 716 | ) -> Result<(), StreamingEditFileToolOutput> { |
| 501 | | − if edits.is_empty() { |
| 502 | | − return Ok(()); |
| 503 | | − } |
| 717 | + for event in events { |
| 718 | + match event { |
| 719 | + ToolEditEvent::ContentChunk { chunk } => { |
| 720 | + cx.update(|cx| { |
| 721 | + buffer.update(cx, |buffer, cx| { |
| 722 | + let insert_at = if !pipeline.content_written && buffer.len() > 0 { |
| 723 | + 0..buffer.len() |
| 724 | + } else { |
| 725 | + let len = buffer.len(); |
| 726 | + len..len |
| 727 | + }; |
| 728 | + buffer.edit([(insert_at, chunk.as_str())], None, cx); |
| 729 | + }); |
| 730 | + let buffer_id = buffer.read(cx).remote_id(); |
| 731 | + tool.set_agent_location( |
| 732 | + buffer.downgrade(), |
| 733 | + text::Anchor::max_for_buffer(buffer_id), |
| 734 | + cx, |
| 735 | + ); |
| 736 | + }); |
| 737 | + pipeline.content_written = true; |
| 738 | + } |
| 504 | 739 | |
| 505 | | − // Edits at indices applied_count..edits.len()-1 are newly complete |
| 506 | | − // (a subsequent edit exists, proving the LLM moved on). |
| 507 | | − // The last edit (edits.len()-1) is potentially still in progress. |
| 508 | | − let completed_count = edits.len().saturating_sub(1); |
| 740 | + ToolEditEvent::OldTextChunk { |
| 741 | + edit_index, |
| 742 | + chunk, |
| 743 | + done: false, |
| 744 | + } => { |
| 745 | + pipeline.ensure_resolving_old_text(*edit_index, buffer, cx); |
| 746 | + |
| 747 | + if let EditPipelineEntry::ResolvingOldText { matcher } = |
| 748 | + &mut pipeline.edits[*edit_index] |
| 749 | + { |
| 750 | + if !chunk.is_empty() { |
| 751 | + if let Some(match_range) = matcher.push(chunk, None) { |
| 752 | + let anchor_range = buffer.read_with(cx, |buffer, _cx| { |
| 753 | + buffer.anchor_range_between(match_range.clone()) |
| 754 | + }); |
| 755 | + diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); |
| 756 | + |
| 757 | + cx.update(|cx| { |
| 758 | + let position = buffer.read(cx).anchor_before(match_range.end); |
| 759 | + tool.set_agent_location(buffer.downgrade(), position, cx); |
| 760 | + }); |
| 761 | + } |
| 762 | + } |
| 763 | + } |
| 764 | + } |
| 509 | 765 | |
| 510 | | − // Apply newly-complete edits |
| 511 | | − while edit_state.applied_count() < completed_count { |
| 512 | | − let edit_index = edit_state.applied_count(); |
| 513 | | − let partial_edit = &edits[edit_index]; |
| 766 | + ToolEditEvent::OldTextChunk { |
| 767 | + edit_index, |
| 768 | + chunk, |
| 769 | + done: true, |
| 770 | + } => { |
| 771 | + pipeline.ensure_resolving_old_text(*edit_index, buffer, cx); |
| 772 | + |
| 773 | + let EditPipelineEntry::ResolvingOldText { matcher } = |
| 774 | + &mut pipeline.edits[*edit_index] |
| 775 | + else { |
| 776 | + continue; |
| 777 | + }; |
| 514 | 778 | |
| 515 | | − let old_text = partial_edit.old_text.clone().ok_or_else(|| { |
| 516 | | − StreamingEditFileToolOutput::Error { |
| 517 | | − error: format!("Edit at index {} is missing old_text.", edit_index), |
| 518 | | − } |
| 519 | | − })?; |
| 520 | | − let new_text = partial_edit.new_text.clone().unwrap_or_default(); |
| 779 | + if !chunk.is_empty() { |
| 780 | + matcher.push(chunk, None); |
| 781 | + } |
| 782 | + let matches = matcher.finish(); |
| 783 | + |
| 784 | + if matches.is_empty() { |
| 785 | + return Err(StreamingEditFileToolOutput::error(format!( |
| 786 | + "Could not find matching text for edit at index {}. \ |
| 787 | + The old_text did not match any content in the file. \ |
| 788 | + Please read the file again to get the current content.", |
| 789 | + edit_index, |
| 790 | + ))); |
| 791 | + } |
| 792 | + if matches.len() > 1 { |
| 793 | + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 794 | + let lines = matches |
| 795 | + .iter() |
| 796 | + .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) |
| 797 | + .collect::<Vec<_>>() |
| 798 | + .join(", "); |
| 799 | + return Err(StreamingEditFileToolOutput::error(format!( |
| 800 | + "Edit {} matched multiple locations in the file at lines: {}. \ |
| 801 | + Please provide more context in old_text to uniquely \ |
| 802 | + identify the location.", |
| 803 | + edit_index, lines |
| 804 | + ))); |
| 805 | + } |
| 521 | 806 | |
| 522 | | − edit_state.in_progress_matcher = None; |
| 523 | | − edit_state.last_old_text_len = 0; |
| 807 | + let range = matches.into_iter().next().expect("checked len above"); |
| 524 | 808 | |
| 525 | | − let edit_op = EditOperation { |
| 526 | | − old_text: old_text.clone(), |
| 527 | | − new_text: new_text.clone(), |
| 528 | | − }; |
| 809 | + let anchor_range = buffer |
| 810 | + .read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); |
| 811 | + diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); |
| 812 | + |
| 813 | + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 529 | 814 | |
| 530 | | − let action_log = tool |
| 531 | | − .thread |
| 532 | | − .read_with(cx, |thread, _cx| thread.action_log().clone()) |
| 533 | | − .ok(); |
| 815 | + let line = snapshot.offset_to_point(range.start).row; |
| 816 | + event_stream.update_fields( |
| 817 | + ToolCallUpdateFields::new() |
| 818 | + .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), |
| 819 | + ); |
| 820 | + |
| 821 | + let EditPipelineEntry::ResolvingOldText { matcher } = |
| 822 | + &pipeline.edits[*edit_index] |
| 823 | + else { |
| 824 | + continue; |
| 825 | + }; |
| 826 | + let buffer_indent = |
| 827 | + snapshot.line_indent_for_row(snapshot.offset_to_point(range.start).row); |
| 828 | + let query_indent = query_first_line_indent(matcher.query_lines()); |
| 829 | + let indent_delta = compute_indent_delta(buffer_indent, query_indent); |
| 830 | + |
| 831 | + let old_text_in_buffer = |
| 832 | + snapshot.text_for_range(range.clone()).collect::<String>(); |
| 833 | + |
| 834 | + let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); |
| 835 | + pipeline.edits[*edit_index] = EditPipelineEntry::StreamingNewText { |
| 836 | + streaming_diff: StreamingDiff::new(old_text_in_buffer), |
| 837 | + edit_cursor: range.start, |
| 838 | + reindenter: Reindenter::new(indent_delta), |
| 839 | + original_snapshot: text_snapshot, |
| 840 | + }; |
| 534 | 841 | |
| 535 | | − // On the first edit, mark the buffer as read |
| 536 | | − if edit_state.applied_count() == 0 { |
| 537 | | − if let Some(action_log) = &action_log { |
| 538 | | − action_log.update(cx, |log, cx| { |
| 539 | | − log.buffer_read(buffer.clone(), cx); |
| 842 | + cx.update(|cx| { |
| 843 | + let position = buffer.read(cx).anchor_before(range.end); |
| 844 | + tool.set_agent_location(buffer.downgrade(), position, cx); |
| 540 | 845 | }); |
| 541 | 846 | } |
| 542 | | − } |
| 543 | 847 | |
| 544 | | − let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 545 | | − |
| 546 | | − let (range, new_text) = |
| 547 | | − match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) { |
| 548 | | − Ok(resolved) => resolved, |
| 549 | | − Err(EditResolveError::NotFound) => { |
| 550 | | − return Err(StreamingEditFileToolOutput::Error { |
| 551 | | − error: format!( |
| 552 | | − "Could not find matching text for edit at index {}. \ |
| 553 | | − The old_text did not match any content in the file. \ |
| 554 | | − Please read the file again to get the current content.", |
| 555 | | − edit_index |
| 556 | | − ), |
| 557 | | − }); |
| 848 | + ToolEditEvent::NewTextChunk { |
| 849 | + edit_index, |
| 850 | + chunk, |
| 851 | + done: false, |
| 852 | + } => { |
| 853 | + if *edit_index >= pipeline.edits.len() { |
| 854 | + continue; |
| 558 | 855 | } |
| 559 | | − Err(EditResolveError::Ambiguous(ranges)) => { |
| 560 | | − let lines = ranges |
| 561 | | − .iter() |
| 562 | | − .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) |
| 563 | | − .collect::<Vec<_>>() |
| 564 | | − .join(", "); |
| 565 | | − return Err(StreamingEditFileToolOutput::Error { |
| 566 | | − error: format!( |
| 567 | | − "Edit {} matched multiple locations in the file at lines: {}. \ |
| 568 | | − Please provide more context in old_text to uniquely \ |
| 569 | | − identify the location.", |
| 570 | | − edit_index, lines |
| 571 | | − ), |
| 572 | | − }); |
| 856 | + let EditPipelineEntry::StreamingNewText { |
| 857 | + streaming_diff, |
| 858 | + edit_cursor, |
| 859 | + reindenter, |
| 860 | + original_snapshot, |
| 861 | + .. |
| 862 | + } = &mut pipeline.edits[*edit_index] |
| 863 | + else { |
| 864 | + continue; |
| 865 | + }; |
| 866 | + |
| 867 | + let reindented = reindenter.push(chunk); |
| 868 | + if reindented.is_empty() { |
| 869 | + continue; |
| 573 | 870 | } |
| 574 | | − }; |
| 575 | 871 | |
| 576 | | − for previous_range in &edit_state.applied_ranges { |
| 577 | | − let previous_start = previous_range.start.to_offset(&snapshot); |
| 578 | | − let previous_end = previous_range.end.to_offset(&snapshot); |
| 579 | | − if range.start < previous_end && previous_start < range.end { |
| 580 | | − let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1; |
| 581 | | − let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1; |
| 582 | | − let later_start_line = snapshot.offset_to_point(range.start).row + 1; |
| 583 | | − let later_end_line = snapshot.offset_to_point(range.end).row + 1; |
| 584 | | − return Err(StreamingEditFileToolOutput::Error { |
| 585 | | − error: format!( |
| 586 | | − "Conflicting edit ranges detected: lines {}-{} \ |
| 587 | | − conflicts with lines {}-{}. Conflicting edit \ |
| 588 | | − ranges are not allowed, as they would overwrite \ |
| 589 | | − each other.", |
| 590 | | − earlier_start_line, earlier_end_line, later_start_line, later_end_line, |
| 591 | | − ), |
| 872 | + let char_ops = streaming_diff.push_new(&reindented); |
| 873 | + Self::apply_char_operations( |
| 874 | + &char_ops, |
| 875 | + buffer, |
| 876 | + original_snapshot, |
| 877 | + edit_cursor, |
| 878 | + cx, |
| 879 | + ); |
| 880 | + |
| 881 | + let position = original_snapshot.anchor_before(*edit_cursor); |
| 882 | + cx.update(|cx| { |
| 883 | + tool.set_agent_location(buffer.downgrade(), position, cx); |
| 592 | 884 | }); |
| 885 | + |
| 886 | + let action_log = tool |
| 887 | + .thread |
| 888 | + .read_with(cx, |thread, _cx| thread.action_log().clone()) |
| 889 | + .ok(); |
| 890 | + if let Some(action_log) = action_log { |
| 891 | + action_log.update(cx, |log, cx| { |
| 892 | + log.buffer_edited(buffer.clone(), cx); |
| 893 | + }); |
| 894 | + } |
| 593 | 895 | } |
| 594 | | − } |
| 595 | 896 | |
| 596 | | − let anchor_range = |
| 597 | | − buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); |
| 598 | | − edit_state.applied_ranges.push(anchor_range); |
| 897 | + ToolEditEvent::NewTextChunk { |
| 898 | + edit_index, |
| 899 | + chunk, |
| 900 | + done: true, |
| 901 | + } => { |
| 902 | + if *edit_index >= pipeline.edits.len() { |
| 903 | + continue; |
| 904 | + } |
| 599 | 905 | |
| 600 | | − let line = snapshot.offset_to_point(range.start).row; |
| 601 | | − event_stream.update_fields( |
| 602 | | − ToolCallUpdateFields::new() |
| 603 | | − .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), |
| 604 | | − ); |
| 906 | + let EditPipelineEntry::StreamingNewText { |
| 907 | + mut streaming_diff, |
| 908 | + mut edit_cursor, |
| 909 | + mut reindenter, |
| 910 | + original_snapshot, |
| 911 | + } = std::mem::replace( |
| 912 | + &mut pipeline.edits[*edit_index], |
| 913 | + EditPipelineEntry::Done, |
| 914 | + ) |
| 915 | + else { |
| 916 | + continue; |
| 917 | + }; |
| 605 | 918 | |
| 606 | | − if let Some(action_log) = action_log { |
| 607 | | − cx.update(|cx| { |
| 608 | | − buffer.update(cx, |buffer, cx| { |
| 609 | | − buffer.edit([(range, new_text.as_str())], None, cx); |
| 610 | | − }); |
| 611 | | − action_log.update(cx, |log, cx| { |
| 612 | | − log.buffer_edited(buffer.clone(), cx); |
| 613 | | − }); |
| 614 | | − }); |
| 615 | | − } |
| 616 | | − } |
| 919 | + // Flush any remaining reindent buffer + final chunk. |
| 920 | + let mut final_text = reindenter.push(chunk); |
| 921 | + final_text.push_str(&reindenter.finish()); |
| 617 | 922 | |
| 618 | | − // Feed the in-progress last edit's old_text to the matcher for live preview |
| 619 | | − if let Some(partial_edit) = edits.last() { |
| 620 | | − if let Some(old_text) = &partial_edit.old_text { |
| 621 | | − let old_text_len = old_text.len(); |
| 622 | | − if old_text_len > edit_state.last_old_text_len { |
| 623 | | − let new_chunk = &old_text[edit_state.last_old_text_len..]; |
| 923 | + if !final_text.is_empty() { |
| 924 | + let char_ops = streaming_diff.push_new(&final_text); |
| 925 | + Self::apply_char_operations( |
| 926 | + &char_ops, |
| 927 | + buffer, |
| 928 | + &original_snapshot, |
| 929 | + &mut edit_cursor, |
| 930 | + cx, |
| 931 | + ); |
| 932 | + } |
| 933 | + |
| 934 | + let remaining_ops = streaming_diff.finish(); |
| 935 | + Self::apply_char_operations( |
| 936 | + &remaining_ops, |
| 937 | + buffer, |
| 938 | + &original_snapshot, |
| 939 | + &mut edit_cursor, |
| 940 | + cx, |
| 941 | + ); |
| 624 | 942 | |
| 625 | | − let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| { |
| 626 | | − let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); |
| 627 | | − StreamingFuzzyMatcher::new(snapshot) |
| 943 | + let position = original_snapshot.anchor_before(edit_cursor); |
| 944 | + cx.update(|cx| { |
| 945 | + tool.set_agent_location(buffer.downgrade(), position, cx); |
| 628 | 946 | }); |
| 629 | 947 | |
| 630 | | − if let Some(match_range) = matcher.push(new_chunk, None) { |
| 631 | | − let anchor_range = buffer.read_with(cx, |buffer, _cx| { |
| 632 | | − buffer.anchor_range_between(match_range.clone()) |
| 948 | + let action_log = tool |
| 949 | + .thread |
| 950 | + .read_with(cx, |thread, _cx| thread.action_log().clone()) |
| 951 | + .ok(); |
| 952 | + if let Some(action_log) = action_log { |
| 953 | + action_log.update(cx, |log, cx| { |
| 954 | + log.buffer_edited(buffer.clone(), cx); |
| 633 | 955 | }); |
| 634 | | − diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx)); |
| 635 | 956 | } |
| 636 | | − |
| 637 | | − edit_state.last_old_text_len = old_text_len; |
| 638 | 957 | } |
| 639 | 958 | } |
| 640 | 959 | } |
| 641 | | − |
| 642 | 960 | Ok(()) |
| 643 | 961 | } |
| 962 | + |
| 963 | + fn apply_char_operations( |
| 964 | + ops: &[CharOperation], |
| 965 | + buffer: &Entity<Buffer>, |
| 966 | + snapshot: &text::BufferSnapshot, |
| 967 | + edit_cursor: &mut usize, |
| 968 | + cx: &mut AsyncApp, |
| 969 | + ) { |
| 970 | + for op in ops { |
| 971 | + match op { |
| 972 | + CharOperation::Insert { text } => { |
| 973 | + let anchor = snapshot.anchor_after(*edit_cursor); |
| 974 | + cx.update(|cx| { |
| 975 | + buffer.update(cx, |buffer, cx| { |
| 976 | + buffer.edit([(anchor..anchor, text.as_str())], None, cx); |
| 977 | + }); |
| 978 | + }); |
| 979 | + } |
| 980 | + CharOperation::Delete { bytes } => { |
| 981 | + let delete_end = *edit_cursor + bytes; |
| 982 | + let anchor_range = snapshot.anchor_range_around(*edit_cursor..delete_end); |
| 983 | + cx.update(|cx| { |
| 984 | + buffer.update(cx, |buffer, cx| { |
| 985 | + buffer.edit([(anchor_range, "")], None, cx); |
| 986 | + }); |
| 987 | + }); |
| 988 | + *edit_cursor = delete_end; |
| 989 | + } |
| 990 | + CharOperation::Keep { bytes } => { |
| 991 | + *edit_cursor += bytes; |
| 992 | + } |
| 993 | + } |
| 994 | + } |
| 995 | + } |
| 644 | 996 | } |
| 645 | 997 | |
| 646 | 998 | fn ensure_buffer_saved( |
| @@ -670,396 +1022,40 @@ fn ensure_buffer_saved( · 23 unmodified lines |
| 670 | 1022 | if is_dirty { |
| 671 | 1023 | let message = match (has_save_tool, has_restore_tool) { |
| 672 | 1024 | (true, true) => { |
| 673 | | − "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 674 | | − If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ |
| 675 | | − If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." |
| 676 | | − } |
| 677 | | − (true, false) => { |
| 678 | | − "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 679 | | − If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ |
| 680 | | − If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." |
| 681 | | − } |
| 682 | | − (false, true) => { |
| 683 | | − "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 684 | | − If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ |
| 685 | | − If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." |
| 686 | | − } |
| 687 | | − (false, false) => { |
| 688 | | − "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ |
| 689 | | − then ask them to save or revert the file manually and inform you when it's ok to proceed." |
| 690 | | − } |
| 691 | | − }; |
| 692 | | − return Err(StreamingEditFileToolOutput::Error { |
| 693 | | − error: message.to_string(), |
| 694 | | − }); |
| 695 | | − } |
| 696 | | − |
| 697 | | − if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { |
| 698 | | − if current != last_read { |
| 699 | | − return Err(StreamingEditFileToolOutput::Error { |
| 700 | | − error: "The file has been modified since you last read it. \ |
| 701 | | − Please read the file again to get the current state before editing it." |
| 702 | | − .to_string(), |
| 703 | | − }); |
| 704 | | − } |
| 705 | | − } |
| 706 | | − |
| 707 | | − Ok(()) |
| 708 | | −} |
| 709 | | − |
| 710 | | −#[derive(Debug, Serialize, Deserialize)] |
| 711 | | −#[serde(untagged)] |
| 712 | | −pub enum StreamingEditFileToolOutput { |
| 713 | | − Success { |
| 714 | | − #[serde(alias = "original_path")] |
| 715 | | − input_path: PathBuf, |
| 716 | | − new_text: String, |
| 717 | | − old_text: Arc<String>, |
| 718 | | − #[serde(default)] |
| 719 | | − diff: String, |
| 720 | | − }, |
| 721 | | − Error { |
| 722 | | − error: String, |
| 723 | | − }, |
| 724 | | −} |
| 725 | | − |
| 726 | | −impl std::fmt::Display for StreamingEditFileToolOutput { |
| 727 | | − fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 728 | | − match self { |
| 729 | | − StreamingEditFileToolOutput::Success { |
| 730 | | − diff, input_path, .. |
| 731 | | − } => { |
| 732 | | − if diff.is_empty() { |
| 733 | | − write!(f, "No edits were made.") |
| 734 | | − } else { |
| 735 | | − write!( |
| 736 | | − f, |
| 737 | | − "Edited {}:\n\n```diff\n{diff}\n```", |
| 738 | | − input_path.display() |
| 739 | | − ) |
| 740 | | − } |
| 741 | | − } |
| 742 | | − StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"), |
| 743 | | − } |
| 744 | | − } |
| 745 | | −} |
| 746 | | − |
| 747 | | −impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent { |
| 748 | | − fn from(output: StreamingEditFileToolOutput) -> Self { |
| 749 | | − output.to_string().into() |
| 750 | | − } |
| 751 | | −} |
| 752 | | − |
| 753 | | −pub struct StreamingEditFileTool { |
| 754 | | − thread: WeakEntity<Thread>, |
| 755 | | − language_registry: Arc<LanguageRegistry>, |
| 756 | | − project: Entity<Project>, |
| 757 | | −} |
| 758 | | − |
| 759 | | −impl StreamingEditFileTool { |
| 760 | | − pub fn new( |
| 761 | | − project: Entity<Project>, |
| 762 | | − thread: WeakEntity<Thread>, |
| 763 | | − language_registry: Arc<LanguageRegistry>, |
| 764 | | − ) -> Self { |
| 765 | | − Self { |
| 766 | | − project, |
| 767 | | − thread, |
| 768 | | − language_registry, |
| 769 | | − } |
| 770 | | − } |
| 771 | | − |
| 772 | | − fn authorize( |
| 773 | | − &self, |
| 774 | | − path: &PathBuf, |
| 775 | | − description: &str, |
| 776 | | − event_stream: &ToolCallEventStream, |
| 777 | | − cx: &mut App, |
| 778 | | − ) -> Task<Result<()>> { |
| 779 | | − super::tool_permissions::authorize_file_edit( |
| 780 | | − EditFileTool::NAME, |
| 781 | | − path, |
| 782 | | − description, |
| 783 | | − &self.thread, |
| 784 | | − event_stream, |
| 785 | | − cx, |
| 786 | | − ) |
| 787 | | − } |
| 788 | | −} |
| 789 | | − |
| 790 | | −impl AgentTool for StreamingEditFileTool { |
| 791 | | − type Input = StreamingEditFileToolInput; |
| 792 | | − type Output = StreamingEditFileToolOutput; |
| 793 | | − |
| 794 | | − const NAME: &'static str = "streaming_edit_file"; |
| 795 | | − |
| 796 | | − fn supports_input_streaming() -> bool { |
| 797 | | − true |
| 798 | | − } |
| 799 | | − |
| 800 | | − fn kind() -> acp::ToolKind { |
| 801 | | − acp::ToolKind::Edit |
| 802 | | − } |
| 803 | | − |
| 804 | | − fn initial_title( |
| 805 | | − &self, |
| 806 | | − input: Result<Self::Input, serde_json::Value>, |
| 807 | | − cx: &mut App, |
| 808 | | − ) -> SharedString { |
| 809 | | − match input { |
| 810 | | − Ok(input) => self |
| 811 | | − .project |
| 812 | | − .read(cx) |
| 813 | | − .find_project_path(&input.path, cx) |
| 814 | | − .and_then(|project_path| { |
| 815 | | − self.project |
| 816 | | − .read(cx) |
| 817 | | − .short_full_path_for_project_path(&project_path, cx) |
| 818 | | − }) |
| 819 | | − .unwrap_or(input.path) |
| 820 | | − .into(), |
| 821 | | − Err(raw_input) => { |
| 822 | | − if let Some(input) = |
| 823 | | − serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok() |
| 824 | | − { |
| 825 | | − let path = input.path.unwrap_or_default(); |
| 826 | | − let path = path.trim(); |
| 827 | | − if !path.is_empty() { |
| 828 | | − return self |
| 829 | | − .project |
| 830 | | − .read(cx) |
| 831 | | − .find_project_path(&path, cx) |
| 832 | | − .and_then(|project_path| { |
| 833 | | − self.project |
| 834 | | − .read(cx) |
| 835 | | − .short_full_path_for_project_path(&project_path, cx) |
| 836 | | − }) |
| 837 | | − .unwrap_or_else(|| path.to_string()) |
| 838 | | − .into(); |
| 839 | | − } |
| 840 | | − |
| 841 | | − let description = input.display_description.unwrap_or_default(); |
| 842 | | − let description = description.trim(); |
| 843 | | − if !description.is_empty() { |
| 844 | | − return description.to_string().into(); |
| 845 | | − } |
| 846 | | − } |
| 847 | | − |
| 848 | | − DEFAULT_UI_TEXT.into() |
| 849 | | − } |
| 850 | | − } |
| 851 | | − } |
| 852 | | − |
| 853 | | − fn run( |
| 854 | | − self: Arc<Self>, |
| 855 | | − mut input: ToolInput<Self::Input>, |
| 856 | | − event_stream: ToolCallEventStream, |
| 857 | | − cx: &mut App, |
| 858 | | − ) -> Task<Result<Self::Output, Self::Output>> { |
| 859 | | − cx.spawn(async move |cx: &mut AsyncApp| { |
| 860 | | − let mut state = StreamingEditState::Idle; |
| 861 | | − loop { |
| 862 | | − futures::select! { |
| 863 | | − partial = input.recv_partial().fuse() => { |
| 864 | | − let Some(partial_value) = partial else { break }; |
| 865 | | − if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) { |
| 866 | | − state.process(parsed, &self, &event_stream, cx).await?; |
| 867 | | − } |
| 868 | | − } |
| 869 | | − _ = event_stream.cancelled_by_user().fuse() => { |
| 870 | | − return Err(StreamingEditFileToolOutput::Error { |
| 871 | | − error: "Edit cancelled by user".to_string(), |
| 872 | | − }); |
| 873 | | − } |
| 874 | | − } |
| 875 | | − } |
| 876 | | − let full_input = |
| 877 | | − input |
| 878 | | − .recv() |
| 879 | | − .await |
| 880 | | − .map_err(|e| StreamingEditFileToolOutput::Error { |
| 881 | | − error: format!("Failed to receive tool input: {e}"), |
| 882 | | − })?; |
| 883 | | − |
| 884 | | − state.finalize(full_input, &self, &event_stream, cx).await |
| 885 | | − }) |
| 886 | | − } |
| 887 | | − |
| 888 | | − fn replay( |
| 889 | | − &self, |
| 890 | | − _input: Self::Input, |
| 891 | | − output: Self::Output, |
| 892 | | − event_stream: ToolCallEventStream, |
| 893 | | − cx: &mut App, |
| 894 | | − ) -> Result<()> { |
| 895 | | − match output { |
| 896 | | − StreamingEditFileToolOutput::Success { |
| 897 | | − input_path, |
| 898 | | − old_text, |
| 899 | | − new_text, |
| 900 | | − .. |
| 901 | | − } => { |
| 902 | | − event_stream.update_diff(cx.new(|cx| { |
| 903 | | − Diff::finalized( |
| 904 | | − input_path.to_string_lossy().into_owned(), |
| 905 | | − Some(old_text.to_string()), |
| 906 | | − new_text, |
| 907 | | − self.language_registry.clone(), |
| 908 | | − cx, |
| 909 | | − ) |
| 910 | | − })); |
| 911 | | − Ok(()) |
| 912 | | − } |
| 913 | | − StreamingEditFileToolOutput::Error { .. } => Ok(()), |
| 914 | | − } |
| 915 | | − } |
| 916 | | −} |
| 917 | | − |
| 918 | | −fn apply_edits( |
| 919 | | − buffer: &Entity<language::Buffer>, |
| 920 | | − action_log: &Entity<action_log::ActionLog>, |
| 921 | | − edits: &[EditOperation], |
| 922 | | − diff: &Entity<Diff>, |
| 923 | | − event_stream: &ToolCallEventStream, |
| 924 | | − abs_path: &PathBuf, |
| 925 | | − cx: &mut AsyncApp, |
| 926 | | −) -> Result<()> { |
| 927 | | − let mut failed_edits = Vec::new(); |
| 928 | | − let mut ambiguous_edits = Vec::new(); |
| 929 | | − let mut resolved_edits: Vec<(Range<usize>, String)> = Vec::new(); |
| 930 | | − |
| 931 | | − let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 932 | | − for (index, edit) in edits.iter().enumerate() { |
| 933 | | − match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) { |
| 934 | | − Ok((range, new_text)) => { |
| 935 | | − resolved_edits.push((range, new_text)); |
| 1025 | + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 1026 | + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ |
| 1027 | + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." |
| 936 | 1028 | } |
| 937 | | − Err(EditResolveError::NotFound) => { |
| 938 | | − failed_edits.push(index); |
| 1029 | + (true, false) => { |
| 1030 | + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 1031 | + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ |
| 1032 | + If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." |
| 939 | 1033 | } |
| 940 | | − Err(EditResolveError::Ambiguous(ranges)) => { |
| 941 | | − ambiguous_edits.push((index, ranges)); |
| 1034 | + (false, true) => { |
| 1035 | + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ |
| 1036 | + If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ |
| 1037 | + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." |
| 942 | 1038 | } |
| 943 | | − } |
| 944 | | − } |
| 945 | | − |
| 946 | | − if !failed_edits.is_empty() { |
| 947 | | − let indices = failed_edits |
| 948 | | − .iter() |
| 949 | | − .map(|i| i.to_string()) |
| 950 | | − .collect::<Vec<_>>() |
| 951 | | − .join(", "); |
| 952 | | − anyhow::bail!( |
| 953 | | − "Could not find matching text for edit(s) at index(es): {}. \ |
| 954 | | − The old_text did not match any content in the file. \ |
| 955 | | − Please read the file again to get the current content.", |
| 956 | | − indices |
| 957 | | − ); |
| 958 | | − } |
| 959 | | − |
| 960 | | − if !ambiguous_edits.is_empty() { |
| 961 | | − let details: Vec<String> = ambiguous_edits |
| 962 | | − .iter() |
| 963 | | − .map(|(index, ranges)| { |
| 964 | | − let lines = ranges |
| 965 | | − .iter() |
| 966 | | − .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) |
| 967 | | − .collect::<Vec<_>>() |
| 968 | | − .join(", "); |
| 969 | | − format!("edit {}: matches at lines {}", index, lines) |
| 970 | | − }) |
| 971 | | − .collect(); |
| 972 | | − anyhow::bail!( |
| 973 | | − "Some edits matched multiple locations in the file:\n{}. \ |
| 974 | | − Please provide more context in old_text to uniquely identify the location.", |
| 975 | | − details.join("\n") |
| 976 | | − ); |
| 977 | | − } |
| 978 | | − |
| 979 | | − let mut edits_sorted = resolved_edits; |
| 980 | | − edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start)); |
| 981 | | − |
| 982 | | − if let Some((first_range, _)) = edits_sorted.first() { |
| 983 | | − let line = snapshot.offset_to_point(first_range.start).row; |
| 984 | | − event_stream.update_fields( |
| 985 | | − ToolCallUpdateFields::new() |
| 986 | | − .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), |
| 987 | | − ); |
| 1039 | + (false, false) => { |
| 1040 | + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ |
| 1041 | + then ask them to save or revert the file manually and inform you when it's ok to proceed." |
| 1042 | + } |
| 1043 | + }; |
| 1044 | + return Err(StreamingEditFileToolOutput::error(message)); |
| 988 | 1045 | } |
| 989 | 1046 | |
| 990 | | − for window in edits_sorted.windows(2) { |
| 991 | | − if let [(earlier_range, _), (later_range, _)] = window |
| 992 | | − && (earlier_range.end > later_range.start || earlier_range.start == later_range.start) |
| 993 | | − { |
| 994 | | − let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1; |
| 995 | | − let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1; |
| 996 | | − let later_start_line = snapshot.offset_to_point(later_range.start).row + 1; |
| 997 | | − let later_end_line = snapshot.offset_to_point(later_range.end).row + 1; |
| 998 | | − anyhow::bail!( |
| 999 | | − "Conflicting edit ranges detected: lines {}-{} conflicts with lines {}-{}. \ |
| 1000 | | − Conflicting edit ranges are not allowed, as they would overwrite each other.", |
| 1001 | | − earlier_start_line, |
| 1002 | | − earlier_end_line, |
| 1003 | | − later_start_line, |
| 1004 | | − later_end_line, |
| 1005 | | − ); |
| 1047 | + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { |
| 1048 | + if current != last_read { |
| 1049 | + return Err(StreamingEditFileToolOutput::error( |
| 1050 | + "The file has been modified since you last read it. \ |
| 1051 | + Please read the file again to get the current state before editing it.", |
| 1052 | + )); |
| 1006 | 1053 | } |
| 1007 | 1054 | } |
| 1008 | 1055 | |
| 1009 | | − if !edits_sorted.is_empty() { |
| 1010 | | − cx.update(|cx| { |
| 1011 | | − buffer.update(cx, |buffer, cx| { |
| 1012 | | − buffer.edit( |
| 1013 | | − edits_sorted |
| 1014 | | − .iter() |
| 1015 | | − .map(|(range, new_text)| (range.clone(), new_text.as_str())), |
| 1016 | | − None, |
| 1017 | | − cx, |
| 1018 | | − ); |
| 1019 | | − }); |
| 1020 | | − action_log.update(cx, |log, cx| { |
| 1021 | | − log.buffer_edited(buffer.clone(), cx); |
| 1022 | | − }); |
| 1023 | | − }); |
| 1024 | | − } |
| 1025 | | − |
| 1026 | 1056 | Ok(()) |
| 1027 | 1057 | } |
| 1028 | 1058 | |
| 1029 | | −enum EditResolveError { |
| 1030 | | − NotFound, |
| 1031 | | − Ambiguous(Vec<Range<usize>>), |
| 1032 | | −} |
| 1033 | | − |
| 1034 | | −/// Resolves an edit operation by finding matching text in the buffer, |
| 1035 | | −/// reveals the matched range in the diff view, and returns the resolved |
| 1036 | | −/// range and replacement text. |
| 1037 | | −fn resolve_and_reveal_edit( |
| 1038 | | − buffer: &Entity<Buffer>, |
| 1039 | | − diff: &Entity<Diff>, |
| 1040 | | − snapshot: &BufferSnapshot, |
| 1041 | | − edit: &EditOperation, |
| 1042 | | − cx: &mut AsyncApp, |
| 1043 | | −) -> std::result::Result<(Range<usize>, String), EditResolveError> { |
| 1044 | | − let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); |
| 1045 | | − matcher.push(&edit.old_text, None); |
| 1046 | | − let matches = matcher.finish(); |
| 1047 | | − if matches.is_empty() { |
| 1048 | | − return Err(EditResolveError::NotFound); |
| 1049 | | − } |
| 1050 | | − if matches.len() > 1 { |
| 1051 | | − return Err(EditResolveError::Ambiguous(matches)); |
| 1052 | | − } |
| 1053 | | − |
| 1054 | | − let range = matches.into_iter().next().expect("checked len above"); |
| 1055 | | − |
| 1056 | | − let anchor_range = |
| 1057 | | − buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); |
| 1058 | | − diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx)); |
| 1059 | | − |
| 1060 | | − Ok((range, edit.new_text.clone())) |
| 1061 | | −} |
| 1062 | | − |
| 1063 | 1059 | fn resolve_path( |
| 1064 | 1060 | mode: StreamingEditFileMode, |
| 1065 | 1061 | path: &PathBuf, |
| @@ -1069,7 +1065,7 @@ fn resolve_path( · 3 unmodified lines |
| 1069 | 1065 | let project = project.read(cx); |
| 1070 | 1066 | |
| 1071 | 1067 | match mode { |
| 1072 | | − StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => { |
| 1068 | + StreamingEditFileMode::Edit => { |
| 1073 | 1069 | let path = project |
| 1074 | 1070 | .find_project_path(&path, cx) |
| 1075 | 1071 | .context("Can't edit file: path not found")?; |
| @@ -1081,13 +1077,12 @@ fn resolve_path( · 5 unmodified lines |
| 1081 | 1077 | anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); |
| 1082 | 1078 | Ok(path) |
| 1083 | 1079 | } |
| 1084 | | − |
| 1085 | | − StreamingEditFileMode::Create => { |
| 1086 | | − if let Some(path) = project.find_project_path(&path, cx) { |
| 1087 | | − anyhow::ensure!( |
| 1088 | | − project.entry_for_path(&path, cx).is_none(), |
| 1089 | | − "Can't create file: file already exists" |
| 1090 | | − ); |
| 1080 | + StreamingEditFileMode::Write => { |
| 1081 | + if let Some(path) = project.find_project_path(&path, cx) |
| 1082 | + && let Some(entry) = project.entry_for_path(&path, cx) |
| 1083 | + { |
| 1084 | + anyhow::ensure!(entry.is_file(), "Can't write to file: path is a directory"); |
| 1085 | + return Ok(path); |
| 1091 | 1086 | } |
| 1092 | 1087 | |
| 1093 | 1088 | let parent_path = path.parent().context("Can't create file: incorrect path")?; |
| @@ -1162,7 +1157,7 @@ mod tests { · 68 unmodified lines |
| 1162 | 1157 | let input = StreamingEditFileToolInput { |
| 1163 | 1158 | display_description: "Create new file".into(), |
| 1164 | 1159 | path: "root/dir/new_file.txt".into(), |
| 1165 | | − mode: StreamingEditFileMode::Create, |
| 1160 | + mode: StreamingEditFileMode::Write, |
| 1166 | 1161 | content: Some("Hello, World!".into()), |
| 1167 | 1162 | edits: None, |
| 1168 | 1163 | }; |
| @@ -1214,7 +1209,7 @@ mod tests { · 45 unmodified lines |
| 1214 | 1209 | let input = StreamingEditFileToolInput { |
| 1215 | 1210 | display_description: "Overwrite file".into(), |
| 1216 | 1211 | path: "root/file.txt".into(), |
| 1217 | | − mode: StreamingEditFileMode::Overwrite, |
| 1212 | + mode: StreamingEditFileMode::Write, |
| 1218 | 1213 | content: Some("new content".into()), |
| 1219 | 1214 | edits: None, |
| 1220 | 1215 | }; |
| @@ -1276,7 +1271,7 @@ mod tests { · 55 unmodified lines |
| 1276 | 1271 | path: "root/file.txt".into(), |
| 1277 | 1272 | mode: StreamingEditFileMode::Edit, |
| 1278 | 1273 | content: None, |
| 1279 | | − edits: Some(vec![EditOperation { |
| 1274 | + edits: Some(vec![Edit { |
| 1280 | 1275 | old_text: "line 2".into(), |
| 1281 | 1276 | new_text: "modified line 2".into(), |
| 1282 | 1277 | }]), |
| @@ -1301,7 +1296,7 @@ mod tests { · 18 unmodified lines |
| 1301 | 1296 | } |
| 1302 | 1297 | |
| 1303 | 1298 | #[gpui::test] |
| 1304 | | − async fn test_streaming_edit_multiple_nonoverlapping_edits(cx: &mut TestAppContext) { |
| 1299 | + async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { |
| 1305 | 1300 | init_test(cx); |
| 1306 | 1301 | |
| 1307 | 1302 | let fs = project::FakeFs::new(cx.executor()); |
| @@ -1336,11 +1331,11 @@ mod tests { · 28 unmodified lines |
| 1336 | 1331 | mode: StreamingEditFileMode::Edit, |
| 1337 | 1332 | content: None, |
| 1338 | 1333 | edits: Some(vec![ |
| 1339 | | − EditOperation { |
| 1334 | + Edit { |
| 1340 | 1335 | old_text: "line 5".into(), |
| 1341 | 1336 | new_text: "modified line 5".into(), |
| 1342 | 1337 | }, |
| 1343 | | − EditOperation { |
| 1338 | + Edit { |
| 1344 | 1339 | old_text: "line 1".into(), |
| 1345 | 1340 | new_text: "modified line 1".into(), |
| 1346 | 1341 | }, |
| @@ -1404,11 +1399,11 @@ mod tests { · 57 unmodified lines |
| 1404 | 1399 | mode: StreamingEditFileMode::Edit, |
| 1405 | 1400 | content: None, |
| 1406 | 1401 | edits: Some(vec![ |
| 1407 | | − EditOperation { |
| 1402 | + Edit { |
| 1408 | 1403 | old_text: "line 2".into(), |
| 1409 | 1404 | new_text: "modified line 2".into(), |
| 1410 | 1405 | }, |
| 1411 | | − EditOperation { |
| 1406 | + Edit { |
| 1412 | 1407 | old_text: "line 3".into(), |
| 1413 | 1408 | new_text: "modified line 3".into(), |
| 1414 | 1409 | }, |
| @@ -1472,11 +1467,11 @@ mod tests { · 57 unmodified lines |
| 1472 | 1467 | mode: StreamingEditFileMode::Edit, |
| 1473 | 1468 | content: None, |
| 1474 | 1469 | edits: Some(vec![ |
| 1475 | | − EditOperation { |
| 1470 | + Edit { |
| 1476 | 1471 | old_text: "line 1".into(), |
| 1477 | 1472 | new_text: "modified line 1".into(), |
| 1478 | 1473 | }, |
| 1479 | | − EditOperation { |
| 1474 | + Edit { |
| 1480 | 1475 | old_text: "line 5".into(), |
| 1481 | 1476 | new_text: "modified line 5".into(), |
| 1482 | 1477 | }, |
| @@ -1533,7 +1528,7 @@ mod tests { · 50 unmodified lines |
| 1533 | 1528 | path: "root/nonexistent_file.txt".into(), |
| 1534 | 1529 | mode: StreamingEditFileMode::Edit, |
| 1535 | 1530 | content: None, |
| 1536 | | − edits: Some(vec![EditOperation { |
| 1531 | + edits: Some(vec![Edit { |
| 1537 | 1532 | old_text: "foo".into(), |
| 1538 | 1533 | new_text: "bar".into(), |
| 1539 | 1534 | }]), |
| @@ -1587,7 +1582,7 @@ mod tests { · 47 unmodified lines |
| 1587 | 1582 | path: "root/file.txt".into(), |
| 1588 | 1583 | mode: StreamingEditFileMode::Edit, |
| 1589 | 1584 | content: None, |
| 1590 | | − edits: Some(vec![EditOperation { |
| 1585 | + edits: Some(vec![Edit { |
| 1591 | 1586 | old_text: "nonexistent text that is not in the file".into(), |
| 1592 | 1587 | new_text: "replacement".into(), |
| 1593 | 1588 | }]), |
| @@ -1614,79 +1609,6 @@ mod tests { · 20 unmodified lines |
| 1614 | 1609 | ); |
| 1615 | 1610 | } |
| 1616 | 1611 | |
| 1617 | | − #[gpui::test] |
| 1618 | | − async fn test_streaming_edit_overlapping_edits_out_of_order(cx: &mut TestAppContext) { |
| 1619 | | − init_test(cx); |
| 1620 | | − |
| 1621 | | − let fs = project::FakeFs::new(cx.executor()); |
| 1622 | | − // Multi-line file so the line-based fuzzy matcher can resolve each edit. |
| 1623 | | − fs.insert_tree( |
| 1624 | | − "/root", |
| 1625 | | − json!({ |
| 1626 | | − "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" |
| 1627 | | − }), |
| 1628 | | − ) |
| 1629 | | − .await; |
| 1630 | | − let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; |
| 1631 | | − let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); |
| 1632 | | − let context_server_registry = |
| 1633 | | − cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); |
| 1634 | | − let model = Arc::new(FakeLanguageModel::default()); |
| 1635 | | − let thread = cx.new(|cx| { |
| 1636 | | − crate::Thread::new( |
| 1637 | | − project.clone(), |
| 1638 | | − cx.new(|_cx| ProjectContext::default()), |
| 1639 | | − context_server_registry, |
| 1640 | | − Templates::new(), |
| 1641 | | − Some(model), |
| 1642 | | − cx, |
| 1643 | | − ) |
| 1644 | | − }); |
| 1645 | | − |
| 1646 | | − // Edit A spans lines 3-4, edit B spans lines 2-3. They overlap on |
| 1647 | | − // "line 3" and are given in descending file order so the ascending |
| 1648 | | − // sort must reorder them before the pairwise overlap check can |
| 1649 | | − // detect them correctly. |
| 1650 | | − let result = cx |
| 1651 | | − .update(|cx| { |
| 1652 | | − let input = StreamingEditFileToolInput { |
| 1653 | | − display_description: "Overlapping edits".into(), |
| 1654 | | − path: "root/file.txt".into(), |
| 1655 | | − mode: StreamingEditFileMode::Edit, |
| 1656 | | − content: None, |
| 1657 | | − edits: Some(vec![ |
| 1658 | | − EditOperation { |
| 1659 | | − old_text: "line 3\nline 4".into(), |
| 1660 | | − new_text: "SECOND".into(), |
| 1661 | | − }, |
| 1662 | | − EditOperation { |
| 1663 | | − old_text: "line 2\nline 3".into(), |
| 1664 | | − new_text: "FIRST".into(), |
| 1665 | | − }, |
| 1666 | | − ]), |
| 1667 | | − }; |
| 1668 | | − Arc::new(StreamingEditFileTool::new( |
| 1669 | | − project, |
| 1670 | | − thread.downgrade(), |
| 1671 | | − language_registry, |
| 1672 | | − )) |
| 1673 | | − .run( |
| 1674 | | − ToolInput::resolved(input), |
| 1675 | | − ToolCallEventStream::test().0, |
| 1676 | | − cx, |
| 1677 | | − ) |
| 1678 | | − }) |
| 1679 | | − .await; |
| 1680 | | − |
| 1681 | | − let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { |
| 1682 | | − panic!("expected error"); |
| 1683 | | − }; |
| 1684 | | − assert!( |
| 1685 | | − error.contains("Conflicting edit ranges detected"), |
| 1686 | | − "Expected 'Conflicting edit ranges detected' but got: {error}" |
| 1687 | | − ); |
| 1688 | | − } |
| 1689 | | − |
| 1690 | 1612 | #[gpui::test] |
| 1691 | 1613 | async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { |
| 1692 | 1614 | init_test(cx); |
| @@ -1809,7 +1731,7 @@ mod tests { · 116 unmodified lines |
| 1809 | 1731 | sender.send_partial(json!({ |
| 1810 | 1732 | "display_description": "Overwrite file", |
| 1811 | 1733 | "path": "root/file.txt", |
| 1812 | | − "mode": "overwrite" |
| 1734 | + "mode": "write" |
| 1813 | 1735 | })); |
| 1814 | 1736 | cx.run_until_parked(); |
| 1815 | 1737 | |
| @@ -1817,7 +1739,7 @@ mod tests { · 1 unmodified line |
| 1817 | 1739 | sender.send_final(json!({ |
| 1818 | 1740 | "display_description": "Overwrite file", |
| 1819 | 1741 | "path": "root/file.txt", |
| 1820 | | − "mode": "overwrite", |
| 1742 | + "mode": "write", |
| 1821 | 1743 | "content": "new content" |
| 1822 | 1744 | })); |
| 1823 | 1745 | |
| @@ -2026,14 +1948,14 @@ mod tests { · 202 unmodified lines |
| 2026 | 1948 | sender.send_partial(json!({ |
| 2027 | 1949 | "display_description": "Create new file", |
| 2028 | 1950 | "path": "root/dir/new_file.txt", |
| 2029 | | − "mode": "create" |
| 1951 | + "mode": "write" |
| 2030 | 1952 | })); |
| 2031 | 1953 | cx.run_until_parked(); |
| 2032 | 1954 | |
| 2033 | 1955 | sender.send_partial(json!({ |
| 2034 | 1956 | "display_description": "Create new file", |
| 2035 | 1957 | "path": "root/dir/new_file.txt", |
| 2036 | | − "mode": "create", |
| 1958 | + "mode": "write", |
| 2037 | 1959 | "content": "Hello, " |
| 2038 | 1960 | })); |
| 2039 | 1961 | cx.run_until_parked(); |
| @@ -2042,7 +1964,7 @@ mod tests { · 2 unmodified lines |
| 2042 | 1964 | sender.send_final(json!({ |
| 2043 | 1965 | "display_description": "Create new file", |
| 2044 | 1966 | "path": "root/dir/new_file.txt", |
| 2045 | | − "mode": "create", |
| 1967 | + "mode": "write", |
| 2046 | 1968 | "content": "Hello, World!" |
| 2047 | 1969 | })); |
| 2048 | 1970 | |
| @@ -2304,14 +2226,16 @@ mod tests { · 255 unmodified lines |
| 2304 | 2226 | })); |
| 2305 | 2227 | cx.run_until_parked(); |
| 2306 | 2228 | |
| 2307 | | − // Verify edit 1 applied |
| 2229 | + // Verify edit 1 fully applied. Edit 2's new_text is being |
| 2230 | + // streamed: "CCC" is inserted but the old "ccc" isn't deleted |
| 2231 | + // yet (StreamingDiff::finish runs when edit 3 marks edit 2 done). |
| 2308 | 2232 | let buffer_text = project.update(cx, |project, cx| { |
| 2309 | 2233 | let pp = project |
| 2310 | 2234 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| 2311 | 2235 | .unwrap(); |
| 2312 | 2236 | project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) |
| 2313 | 2237 | }); |
| 2314 | | − assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nccc\nddd\neee\n")); |
| 2238 | + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n")); |
| 2315 | 2239 | |
| 2316 | 2240 | // Edit 3 appears — edit 2 is now complete and should be applied |
| 2317 | 2241 | sender.send_partial(json!({ |
| @@ -2326,14 +2250,15 @@ mod tests { · 8 unmodified lines |
| 2326 | 2250 | })); |
| 2327 | 2251 | cx.run_until_parked(); |
| 2328 | 2252 | |
| 2329 | | − // Verify edits 1 and 2 both applied |
| 2253 | + // Verify edits 1 and 2 fully applied. Edit 3's new_text is being |
| 2254 | + // streamed: "EEE" is inserted but old "eee" isn't deleted yet. |
| 2330 | 2255 | let buffer_text = project.update(cx, |project, cx| { |
| 2331 | 2256 | let pp = project |
| 2332 | 2257 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| 2333 | 2258 | .unwrap(); |
| 2334 | 2259 | project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) |
| 2335 | 2260 | }); |
| 2336 | | − assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\neee\n")); |
| 2261 | + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\nEEEeee\n")); |
| 2337 | 2262 | |
| 2338 | 2263 | // Send final |
| 2339 | 2264 | sender.send_final(json!({ |
| @@ -2466,82 +2391,6 @@ mod tests { · 126 unmodified lines |
| 2466 | 2391 | ); |
| 2467 | 2392 | } |
| 2468 | 2393 | |
| 2469 | | − #[gpui::test] |
| 2470 | | − async fn test_streaming_overlapping_edits_detected_naturally(cx: &mut TestAppContext) { |
| 2471 | | − init_test(cx); |
| 2472 | | − |
| 2473 | | − let fs = project::FakeFs::new(cx.executor()); |
| 2474 | | − fs.insert_tree( |
| 2475 | | − "/root", |
| 2476 | | − json!({ |
| 2477 | | − "file.txt": "line 1\nline 2\nline 3\n" |
| 2478 | | − }), |
| 2479 | | − ) |
| 2480 | | − .await; |
| 2481 | | − let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; |
| 2482 | | − let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); |
| 2483 | | − let context_server_registry = |
| 2484 | | − cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); |
| 2485 | | − let model = Arc::new(FakeLanguageModel::default()); |
| 2486 | | − let thread = cx.new(|cx| { |
| 2487 | | − crate::Thread::new( |
| 2488 | | − project.clone(), |
| 2489 | | − cx.new(|_cx| ProjectContext::default()), |
| 2490 | | − context_server_registry, |
| 2491 | | − Templates::new(), |
| 2492 | | − Some(model), |
| 2493 | | − cx, |
| 2494 | | − ) |
| 2495 | | − }); |
| 2496 | | − |
| 2497 | | − let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test(); |
| 2498 | | − let (event_stream, _receiver) = ToolCallEventStream::test(); |
| 2499 | | − |
| 2500 | | − let tool = Arc::new(StreamingEditFileTool::new( |
| 2501 | | − project.clone(), |
| 2502 | | − thread.downgrade(), |
| 2503 | | − language_registry, |
| 2504 | | − )); |
| 2505 | | − |
| 2506 | | − let task = cx.update(|cx| tool.run(input, event_stream, cx)); |
| 2507 | | − |
| 2508 | | − // Setup |
| 2509 | | − sender.send_partial(json!({ |
| 2510 | | − "display_description": "Overlapping edits", |
| 2511 | | − "path": "root/file.txt", |
| 2512 | | − "mode": "edit" |
| 2513 | | − })); |
| 2514 | | − cx.run_until_parked(); |
| 2515 | | − |
| 2516 | | − // Edit 1 targets "line 1\nline 2" and replaces it. |
| 2517 | | − // Edit 2 targets "line 2\nline 3" — but after edit 1 is applied, |
| 2518 | | − // "line 2" has been removed so this should fail to match. |
| 2519 | | − // Edit 3 exists to make edit 2 "complete" during streaming. |
| 2520 | | − sender.send_partial(json!({ |
| 2521 | | − "display_description": "Overlapping edits", |
| 2522 | | − "path": "root/file.txt", |
| 2523 | | − "mode": "edit", |
| 2524 | | − "edits": [ |
| 2525 | | − {"old_text": "line 1\nline 2", "new_text": "REPLACED"}, |
| 2526 | | − {"old_text": "line 2\nline 3", "new_text": "ALSO REPLACED"}, |
| 2527 | | − {"old_text": "line 3", "new_text": "DUMMY"} |
| 2528 | | − ] |
| 2529 | | − })); |
| 2530 | | − cx.run_until_parked(); |
| 2531 | | − |
| 2532 | | − // Edit 1 was applied, edit 2 should fail since "line 2" no longer exists |
| 2533 | | − drop(sender); |
| 2534 | | − |
| 2535 | | − let result = task.await; |
| 2536 | | − let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { |
| 2537 | | − panic!("expected error"); |
| 2538 | | − }; |
| 2539 | | − assert!( |
| 2540 | | − error.contains("Could not find matching text for edit at index 1"), |
| 2541 | | − "Expected overlapping edit to fail naturally, got: {error}" |
| 2542 | | − ); |
| 2543 | | − } |
| 2544 | | − |
| 2545 | 2394 | #[gpui::test] |
| 2546 | 2395 | async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { |
| 2547 | 2396 | init_test(cx); |
| @@ -2590,7 +2439,10 @@ mod tests { · 42 unmodified lines |
| 2590 | 2439 | })); |
| 2591 | 2440 | cx.run_until_parked(); |
| 2592 | 2441 | |
| 2593 | | − // Buffer should NOT be modified — the single edit is still in-progress |
| 2442 | + // The edit's old_text and new_text both arrived in one partial, so |
| 2443 | + // the old_text is resolved and new_text is being streamed via |
| 2444 | + // StreamingDiff. The buffer reflects the in-progress diff (new text |
| 2445 | + // inserted, old text not yet fully removed until finalization). |
| 2594 | 2446 | let buffer_text = project.update(cx, |project, cx| { |
| 2595 | 2447 | let pp = project |
| 2596 | 2448 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| @@ -2599,8 +2451,8 @@ mod tests { · 2 unmodified lines |
| 2599 | 2451 | }); |
| 2600 | 2452 | assert_eq!( |
| 2601 | 2453 | buffer_text.as_deref(), |
| 2602 | | − Some("hello world\n"), |
| 2603 | | − "Single in-progress edit should not be applied during streaming" |
| 2454 | + Some("goodbye worldhello world\n"), |
| 2455 | + "In-progress streaming diff: new text inserted, old text not yet removed" |
| 2604 | 2456 | ); |
| 2605 | 2457 | |
| 2606 | 2458 | // Send final — the edit is applied during finalization |
| @@ -2795,12 +2647,12 @@ mod tests { · 188 unmodified lines |
| 2795 | 2647 | sender.send_partial(json!({ |
| 2796 | 2648 | "display_description": "Create", |
| 2797 | 2649 | "path": "root/dir/new.txt", |
| 2798 | | − "mode": "create" |
| 2650 | + "mode": "write" |
| 2799 | 2651 | })); |
| 2800 | 2652 | sender.send_final(json!({ |
| 2801 | 2653 | "display_description": "Create", |
| 2802 | 2654 | "path": "root/dir/new.txt", |
| 2803 | | − "mode": "create", |
| 2655 | + "mode": "write", |
| 2804 | 2656 | "content": "streamed content" |
| 2805 | 2657 | })); |
| 2806 | 2658 | |
| @@ -2813,7 +2665,7 @@ mod tests { · 6 unmodified lines |
| 2813 | 2665 | |
| 2814 | 2666 | #[gpui::test] |
| 2815 | 2667 | async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { |
| 2816 | | − let mode = StreamingEditFileMode::Create; |
| 2668 | + let mode = StreamingEditFileMode::Write; |
| 2817 | 2669 | |
| 2818 | 2670 | let result = test_resolve_path(&mode, "root/new.txt", cx); |
| 2819 | 2671 | assert_resolved_path_eq(result.await, rel_path("new.txt")); |
| @@ -2825,9 +2677,12 @@ mod tests { · 5 unmodified lines |
| 2825 | 2677 | assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); |
| 2826 | 2678 | |
| 2827 | 2679 | let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); |
| 2680 | + assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt")); |
| 2681 | + |
| 2682 | + let result = test_resolve_path(&mode, "root/dir/subdir", cx); |
| 2828 | 2683 | assert_eq!( |
| 2829 | 2684 | result.await.unwrap_err().to_string(), |
| 2830 | | − "Can't create file: file already exists" |
| 2685 | + "Can't write to file: path is a directory" |
| 2831 | 2686 | ); |
| 2832 | 2687 | |
| 2833 | 2688 | let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); |
| @@ -3003,14 +2858,14 @@ mod tests { · 169 unmodified lines |
| 3003 | 2858 | sender.send_partial(json!({ |
| 3004 | 2859 | "display_description": "Create main function", |
| 3005 | 2860 | "path": "root/src/main.rs", |
| 3006 | | − "mode": "overwrite" |
| 2861 | + "mode": "write" |
| 3007 | 2862 | })); |
| 3008 | 2863 | cx.run_until_parked(); |
| 3009 | 2864 | |
| 3010 | 2865 | sender.send_final(json!({ |
| 3011 | 2866 | "display_description": "Create main function", |
| 3012 | 2867 | "path": "root/src/main.rs", |
| 3013 | | − "mode": "overwrite", |
| 2868 | + "mode": "write", |
| 3014 | 2869 | "content": UNFORMATTED_CONTENT |
| 3015 | 2870 | })); |
| 3016 | 2871 | |
| @@ -3060,14 +2915,14 @@ mod tests { · 43 unmodified lines |
| 3060 | 2915 | sender.send_partial(json!({ |
| 3061 | 2916 | "display_description": "Update main function", |
| 3062 | 2917 | "path": "root/src/main.rs", |
| 3063 | | − "mode": "overwrite" |
| 2918 | + "mode": "write" |
| 3064 | 2919 | })); |
| 3065 | 2920 | cx.run_until_parked(); |
| 3066 | 2921 | |
| 3067 | 2922 | sender.send_final(json!({ |
| 3068 | 2923 | "display_description": "Update main function", |
| 3069 | 2924 | "path": "root/src/main.rs", |
| 3070 | | − "mode": "overwrite", |
| 2925 | + "mode": "write", |
| 3071 | 2926 | "content": UNFORMATTED_CONTENT |
| 3072 | 2927 | })); |
| 3073 | 2928 | |
| @@ -3136,7 +2991,7 @@ mod tests { · 62 unmodified lines |
| 3136 | 2991 | let input = StreamingEditFileToolInput { |
| 3137 | 2992 | display_description: "Create main function".into(), |
| 3138 | 2993 | path: "root/src/main.rs".into(), |
| 3139 | | − mode: StreamingEditFileMode::Overwrite, |
| 2994 | + mode: StreamingEditFileMode::Write, |
| 3140 | 2995 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), |
| 3141 | 2996 | edits: None, |
| 3142 | 2997 | }; |
| @@ -3183,7 +3038,7 @@ mod tests { · 40 unmodified lines |
| 3183 | 3038 | let input = StreamingEditFileToolInput { |
| 3184 | 3039 | display_description: "Update main function".into(), |
| 3185 | 3040 | path: "root/src/main.rs".into(), |
| 3186 | | − mode: StreamingEditFileMode::Overwrite, |
| 3041 | + mode: StreamingEditFileMode::Write, |
| 3187 | 3042 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), |
| 3188 | 3043 | edits: None, |
| 3189 | 3044 | }; |
| @@ -3904,11 +3759,7 @@ mod tests { · 714 unmodified lines |
| 3904 | 3759 | language_registry, |
| 3905 | 3760 | )); |
| 3906 | 3761 | |
| 3907 | | − let modes = vec![ |
| 3908 | | − StreamingEditFileMode::Edit, |
| 3909 | | − StreamingEditFileMode::Create, |
| 3910 | | − StreamingEditFileMode::Overwrite, |
| 3911 | | − ]; |
| 3762 | + let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write]; |
| 3912 | 3763 | |
| 3913 | 3764 | for _mode in modes { |
| 3914 | 3765 | // Test .zed path with different modes |
| @@ -4061,7 +3912,7 @@ mod tests { · 146 unmodified lines |
| 4061 | 3912 | ToolInput::resolved(StreamingEditFileToolInput { |
| 4062 | 3913 | display_description: "Edit file".into(), |
| 4063 | 3914 | path: path!("/main.rs").into(), |
| 4064 | | − mode: StreamingEditFileMode::Overwrite, |
| 3915 | + mode: StreamingEditFileMode::Write, |
| 4065 | 3916 | content: Some("new content".into()), |
| 4066 | 3917 | edits: None, |
| 4067 | 3918 | }), |
| @@ -4090,7 +3941,7 @@ mod tests { · 22 unmodified lines |
| 4090 | 3941 | ToolInput::resolved(StreamingEditFileToolInput { |
| 4091 | 3942 | display_description: "Edit file".into(), |
| 4092 | 3943 | path: path!("/main.rs").into(), |
| 4093 | | − mode: StreamingEditFileMode::Overwrite, |
| 3944 | + mode: StreamingEditFileMode::Write, |
| 4094 | 3945 | content: Some("dropped content".into()), |
| 4095 | 3946 | edits: None, |
| 4096 | 3947 | }), |
| @@ -4171,7 +4022,7 @@ mod tests { · 74 unmodified lines |
| 4171 | 4022 | path: "root/test.txt".into(), |
| 4172 | 4023 | mode: StreamingEditFileMode::Edit, |
| 4173 | 4024 | content: None, |
| 4174 | | − edits: Some(vec![EditOperation { |
| 4025 | + edits: Some(vec![Edit { |
| 4175 | 4026 | old_text: "original content".into(), |
| 4176 | 4027 | new_text: "modified content".into(), |
| 4177 | 4028 | }]), |
| @@ -4196,7 +4047,7 @@ mod tests { · 18 unmodified lines |
| 4196 | 4047 | path: "root/test.txt".into(), |
| 4197 | 4048 | mode: StreamingEditFileMode::Edit, |
| 4198 | 4049 | content: None, |
| 4199 | | − edits: Some(vec![EditOperation { |
| 4050 | + edits: Some(vec![Edit { |
| 4200 | 4051 | old_text: "modified content".into(), |
| 4201 | 4052 | new_text: "further modified content".into(), |
| 4202 | 4053 | }]), |
| @@ -4305,7 +4156,7 @@ mod tests { · 102 unmodified lines |
| 4305 | 4156 | path: "root/test.txt".into(), |
| 4306 | 4157 | mode: StreamingEditFileMode::Edit, |
| 4307 | 4158 | content: None, |
| 4308 | | − edits: Some(vec![EditOperation { |
| 4159 | + edits: Some(vec![Edit { |
| 4309 | 4160 | old_text: "externally modified content".into(), |
| 4310 | 4161 | new_text: "new content".into(), |
| 4311 | 4162 | }]), |
| @@ -4409,7 +4260,7 @@ mod tests { · 97 unmodified lines |
| 4409 | 4260 | path: "root/test.txt".into(), |
| 4410 | 4261 | mode: StreamingEditFileMode::Edit, |
| 4411 | 4262 | content: None, |
| 4412 | | − edits: Some(vec![EditOperation { |
| 4263 | + edits: Some(vec![Edit { |
| 4413 | 4264 | old_text: "original content".into(), |
| 4414 | 4265 | new_text: "new content".into(), |
| 4415 | 4266 | }]), |
| @@ -4441,15 +4292,14 @@ mod tests { · 25 unmodified lines |
| 4441 | 4292 | } |
| 4442 | 4293 | |
| 4443 | 4294 | #[gpui::test] |
| 4444 | | − async fn test_streaming_overlapping_edits_detected_early(cx: &mut TestAppContext) { |
| 4295 | + async fn test_streaming_overlapping_edits_resolved_sequentially(cx: &mut TestAppContext) { |
| 4445 | 4296 | init_test(cx); |
| 4446 | 4297 | |
| 4447 | 4298 | let fs = project::FakeFs::new(cx.executor()); |
| 4448 | | − // The file content is crafted so that edit 1's replacement still |
| 4449 | | − // contains the old_text of edit 2 as a contiguous substring. |
| 4450 | | − // Without early overlap detection, edit 2 would silently match |
| 4451 | | − // inside the already-modified region and corrupt the file instead |
| 4452 | | − // of producing a clear "Conflicting edit ranges" error. |
| 4299 | + // Edit 1's replacement introduces text that contains edit 2's |
| 4300 | + // old_text as a substring. Because edits resolve sequentially |
| 4301 | + // against the current buffer, edit 2 finds a unique match in |
| 4302 | + // the modified buffer and succeeds. |
| 4453 | 4303 | fs.insert_tree( |
| 4454 | 4304 | "/root", |
| 4455 | 4305 | json!({ |
| @@ -4492,17 +4342,10 @@ mod tests { · 36 unmodified lines |
| 4492 | 4342 | })); |
| 4493 | 4343 | cx.run_until_parked(); |
| 4494 | 4344 | |
| 4495 | | − // Edit 1 targets "bbb\nccc" (lines 2-3) and replaces it with |
| 4496 | | − // text that preserves "ccc\nddd" as a contiguous substring in the |
| 4497 | | − // buffer — so edit 2's old_text will still match after edit 1 is |
| 4498 | | − // applied. |
| 4499 | | − // |
| 4500 | | − // Edit 2 targets "ccc\nddd" (lines 3-4), overlapping with edit 1 on |
| 4501 | | − // line 3 ("ccc"). After edit 1 runs, the buffer becomes: |
| 4502 | | − // "aaa\nXXX\nccc\nddd\nddd\neee\n" |
| 4503 | | − // and "ccc\nddd" is still present, so edit 2 would silently |
| 4504 | | − // succeed without early overlap detection. |
| 4505 | | − // |
| 4345 | + // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the |
| 4346 | + // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n". |
| 4347 | + // Edit 2's old_text "ccc\nddd" matches the first occurrence |
| 4348 | + // in the modified buffer and replaces it with "ZZZ". |
| 4506 | 4349 | // Edit 3 exists only to mark edit 2 as "complete" during streaming. |
| 4507 | 4350 | sender.send_partial(json!({ |
| 4508 | 4351 | "display_description": "Overlapping edits", |
| @@ -4529,23 +4372,10 @@ mod tests { · 20 unmodified lines |
| 4529 | 4372 | })); |
| 4530 | 4373 | |
| 4531 | 4374 | let result = task.await; |
| 4532 | | − // We expect a "Conflicting edit ranges" error. Currently the overlap |
| 4533 | | − // goes undetected during streaming and the file gets silently |
| 4534 | | − // corrupted, so this assertion will fail until we add early overlap |
| 4535 | | − // detection. |
| 4536 | | − match result { |
| 4537 | | − Err(StreamingEditFileToolOutput::Error { error }) |
| 4538 | | − if error.contains("Conflicting edit ranges") => {} |
| 4539 | | − Err(StreamingEditFileToolOutput::Error { error }) => { |
| 4540 | | − panic!("Expected 'Conflicting edit ranges' error, got different error: {error}"); |
| 4541 | | − } |
| 4542 | | − Ok(output) => { |
| 4543 | | − panic!("Expected 'Conflicting edit ranges' error, but got success: {output}"); |
| 4544 | | − } |
| 4545 | | − Err(other) => { |
| 4546 | | − panic!("Expected 'Conflicting edit ranges' error, got unexpected output: {other}"); |
| 4547 | | − } |
| 4548 | | − } |
| 4375 | + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { |
| 4376 | + panic!("expected success"); |
| 4377 | + }; |
| 4378 | + assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n"); |
| 4549 | 4379 | } |
| 4550 | 4380 | |
| 4551 | 4381 | #[gpui::test] |
| @@ -4585,7 +4415,7 @@ mod tests { · 33 unmodified lines |
| 4585 | 4415 | sender.send_partial(json!({ |
| 4586 | 4416 | "display_description": "Create new file", |
| 4587 | 4417 | "path": "root/dir/new_file.txt", |
| 4588 | | − "mode": "create" |
| 4418 | + "mode": "write" |
| 4589 | 4419 | })); |
| 4590 | 4420 | cx.run_until_parked(); |
| 4591 | 4421 | |
| @@ -4593,7 +4423,7 @@ mod tests { · 1 unmodified line |
| 4593 | 4423 | sender.send_partial(json!({ |
| 4594 | 4424 | "display_description": "Create new file", |
| 4595 | 4425 | "path": "root/dir/new_file.txt", |
| 4596 | | − "mode": "create", |
| 4426 | + "mode": "write", |
| 4597 | 4427 | "content": "line 1\n" |
| 4598 | 4428 | })); |
| 4599 | 4429 | cx.run_until_parked(); |
| @@ -4611,7 +4441,7 @@ mod tests { · 11 unmodified lines |
| 4611 | 4441 | sender.send_partial(json!({ |
| 4612 | 4442 | "display_description": "Create new file", |
| 4613 | 4443 | "path": "root/dir/new_file.txt", |
| 4614 | | − "mode": "create", |
| 4444 | + "mode": "write", |
| 4615 | 4445 | "content": "line 1\nline 2\n" |
| 4616 | 4446 | })); |
| 4617 | 4447 | cx.run_until_parked(); |
| @@ -4621,7 +4451,7 @@ mod tests { · 3 unmodified lines |
| 4621 | 4451 | sender.send_partial(json!({ |
| 4622 | 4452 | "display_description": "Create new file", |
| 4623 | 4453 | "path": "root/dir/new_file.txt", |
| 4624 | | − "mode": "create", |
| 4454 | + "mode": "write", |
| 4625 | 4455 | "content": "line 1\nline 2\nline 3\n" |
| 4626 | 4456 | })); |
| 4627 | 4457 | cx.run_until_parked(); |
| @@ -4634,7 +4464,7 @@ mod tests { · 6 unmodified lines |
| 4634 | 4464 | sender.send_final(json!({ |
| 4635 | 4465 | "display_description": "Create new file", |
| 4636 | 4466 | "path": "root/dir/new_file.txt", |
| 4637 | | − "mode": "create", |
| 4467 | + "mode": "write", |
| 4638 | 4468 | "content": "line 1\nline 2\nline 3\n" |
| 4639 | 4469 | })); |
| 4640 | 4470 | |
| @@ -4688,7 +4518,7 @@ mod tests { · 47 unmodified lines |
| 4688 | 4518 | sender.send_partial(json!({ |
| 4689 | 4519 | "display_description": "Overwrite file", |
| 4690 | 4520 | "path": "root/file.txt", |
| 4691 | | − "mode": "overwrite" |
| 4521 | + "mode": "write" |
| 4692 | 4522 | })); |
| 4693 | 4523 | cx.run_until_parked(); |
| 4694 | 4524 | |
| @@ -4706,7 +4536,7 @@ mod tests { · 11 unmodified lines |
| 4706 | 4536 | sender.send_partial(json!({ |
| 4707 | 4537 | "display_description": "Overwrite file", |
| 4708 | 4538 | "path": "root/file.txt", |
| 4709 | | − "mode": "overwrite", |
| 4539 | + "mode": "write", |
| 4710 | 4540 | "content": "new line 1\n" |
| 4711 | 4541 | })); |
| 4712 | 4542 | cx.run_until_parked(); |
| @@ -4720,7 +4550,7 @@ mod tests { · 7 unmodified lines |
| 4720 | 4550 | sender.send_final(json!({ |
| 4721 | 4551 | "display_description": "Overwrite file", |
| 4722 | 4552 | "path": "root/file.txt", |
| 4723 | | − "mode": "overwrite", |
| 4553 | + "mode": "write", |
| 4724 | 4554 | "content": "new line 1\nnew line 2\n" |
| 4725 | 4555 | })); |
| 4726 | 4556 | |
| @@ -4781,7 +4611,7 @@ mod tests { · 54 unmodified lines |
| 4781 | 4611 | sender.send_partial(json!({ |
| 4782 | 4612 | "display_description": "Overwrite file", |
| 4783 | 4613 | "path": "root/file.txt", |
| 4784 | | − "mode": "overwrite" |
| 4614 | + "mode": "write" |
| 4785 | 4615 | })); |
| 4786 | 4616 | cx.run_until_parked(); |
| 4787 | 4617 | |
| @@ -4799,7 +4629,7 @@ mod tests { · 11 unmodified lines |
| 4799 | 4629 | sender.send_partial(json!({ |
| 4800 | 4630 | "display_description": "Overwrite file", |
| 4801 | 4631 | "path": "root/file.txt", |
| 4802 | | − "mode": "overwrite", |
| 4632 | + "mode": "write", |
| 4803 | 4633 | "content": "new line 1\n" |
| 4804 | 4634 | })); |
| 4805 | 4635 | cx.run_until_parked(); |
| @@ -4809,7 +4639,7 @@ mod tests { · 3 unmodified lines |
| 4809 | 4639 | sender.send_partial(json!({ |
| 4810 | 4640 | "display_description": "Overwrite file", |
| 4811 | 4641 | "path": "root/file.txt", |
| 4812 | | − "mode": "overwrite", |
| 4642 | + "mode": "write", |
| 4813 | 4643 | "content": "new line 1\nnew line 2\n" |
| 4814 | 4644 | })); |
| 4815 | 4645 | cx.run_until_parked(); |
| @@ -4822,7 +4652,7 @@ mod tests { · 6 unmodified lines |
| 4822 | 4652 | sender.send_final(json!({ |
| 4823 | 4653 | "display_description": "Overwrite file", |
| 4824 | 4654 | "path": "root/file.txt", |
| 4825 | | − "mode": "overwrite", |
| 4655 | + "mode": "write", |
| 4826 | 4656 | "content": "new line 1\nnew line 2\nnew line 3\n" |
| 4827 | 4657 | })); |
| 4828 | 4658 | |
| @@ -4837,6 +4667,89 @@ mod tests { · 8 unmodified lines |
| 4837 | 4667 | assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); |
| 4838 | 4668 | } |
| 4839 | 4669 | |
| 4670 | + #[gpui::test] |
| 4671 | + async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) { |
| 4672 | + init_test(cx); |
| 4673 | + |
| 4674 | + let fs = project::FakeFs::new(cx.executor()); |
| 4675 | + fs.insert_tree( |
| 4676 | + "/root", |
| 4677 | + json!({ |
| 4678 | + "file.txt": "hello\nworld\nfoo\n" |
| 4679 | + }), |
| 4680 | + ) |
| 4681 | + .await; |
| 4682 | + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; |
| 4683 | + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); |
| 4684 | + let context_server_registry = |
| 4685 | + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); |
| 4686 | + let model = Arc::new(FakeLanguageModel::default()); |
| 4687 | + let thread = cx.new(|cx| { |
| 4688 | + crate::Thread::new( |
| 4689 | + project.clone(), |
| 4690 | + cx.new(|_cx| ProjectContext::default()), |
| 4691 | + context_server_registry, |
| 4692 | + Templates::new(), |
| 4693 | + Some(model), |
| 4694 | + cx, |
| 4695 | + ) |
| 4696 | + }); |
| 4697 | + |
| 4698 | + let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test(); |
| 4699 | + let (event_stream, _receiver) = ToolCallEventStream::test(); |
| 4700 | + |
| 4701 | + let tool = Arc::new(StreamingEditFileTool::new( |
| 4702 | + project.clone(), |
| 4703 | + thread.downgrade(), |
| 4704 | + language_registry, |
| 4705 | + )); |
| 4706 | + |
| 4707 | + let task = cx.update(|cx| tool.run(input, event_stream, cx)); |
| 4708 | + |
| 4709 | + sender.send_partial(json!({ |
| 4710 | + "display_description": "Edit", |
| 4711 | + "path": "root/file.txt", |
| 4712 | + "mode": "edit" |
| 4713 | + })); |
| 4714 | + cx.run_until_parked(); |
| 4715 | + |
| 4716 | + // Simulate JSON fixer producing a literal backslash when the LLM |
| 4717 | + // stream cuts in the middle of a \n escape sequence. |
| 4718 | + // The old_text "hello\nworld" would be streamed as: |
| 4719 | + // partial 1: old_text = "hello\\" (fixer closes incomplete \n as \\) |
| 4720 | + // partial 2: old_text = "hello\nworld" (fixer corrected the escape) |
| 4721 | + sender.send_partial(json!({ |
| 4722 | + "display_description": "Edit", |
| 4723 | + "path": "root/file.txt", |
| 4724 | + "mode": "edit", |
| 4725 | + "edits": [{"old_text": "hello\\"}] |
| 4726 | + })); |
| 4727 | + cx.run_until_parked(); |
| 4728 | + |
| 4729 | + // Now the fixer corrects it to the real newline. |
| 4730 | + sender.send_partial(json!({ |
| 4731 | + "display_description": "Edit", |
| 4732 | + "path": "root/file.txt", |
| 4733 | + "mode": "edit", |
| 4734 | + "edits": [{"old_text": "hello\nworld"}] |
| 4735 | + })); |
| 4736 | + cx.run_until_parked(); |
| 4737 | + |
| 4738 | + // Send final. |
| 4739 | + sender.send_final(json!({ |
| 4740 | + "display_description": "Edit", |
| 4741 | + "path": "root/file.txt", |
| 4742 | + "mode": "edit", |
| 4743 | + "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}] |
| 4744 | + })); |
| 4745 | + |
| 4746 | + let result = task.await; |
| 4747 | + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { |
| 4748 | + panic!("expected success"); |
| 4749 | + }; |
| 4750 | + assert_eq!(new_text, "HELLO\nWORLD\nfoo\n"); |
| 4751 | + } |
| 4752 | + |
| 4840 | 4753 | fn init_test(cx: &mut TestAppContext) { |
| 4841 | 4754 | cx.update(|cx| { |
| 4842 | 4755 | let settings_store = SettingsStore::test(cx); |