| @@ -1,13 +1,17 @@ |
| 1 | use super::edit_file_tool::EditFileTool; | 1 | use super::edit_file_tool::EditFileTool; |
| 2 | use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; | 2 | use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; |
| 3 | use super::save_file_tool::SaveFileTool; | 3 | use super::save_file_tool::SaveFileTool; |
| | 4 | use super::tool_edit_parser::{ToolEditEvent, ToolEditParser}; |
| 4 | use crate::{ | 5 | use crate::{ |
| 5 | AgentTool, Thread, ToolCallEventStream, ToolInput, | 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 | use acp_thread::Diff; | 12 | use acp_thread::Diff; |
| 9 | use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; | 13 | use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; |
| 10 | use anyhow::{Context as _, Result, anyhow}; | 14 | use anyhow::{Context as _, Result}; |
| 11 | use collections::HashSet; | 15 | use collections::HashSet; |
| 12 | use futures::FutureExt as _; | 16 | use futures::FutureExt as _; |
| 13 | use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; | 17 | use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; |
| @@ -15,16 +19,15 @@ use language::language_settings::{self, FormatOnSave}; · 1 unmodified line |
| 15 | use language::{Buffer, LanguageRegistry}; | 19 | use language::{Buffer, LanguageRegistry}; |
| 16 | use language_model::LanguageModelToolResultContent; | 20 | use language_model::LanguageModelToolResultContent; |
| 17 | use project::lsp_store::{FormatTrigger, LspFormatTarget}; | 21 | use project::lsp_store::{FormatTrigger, LspFormatTarget}; |
| 18 | use project::{Project, ProjectPath}; | 22 | use project::{AgentLocation, Project, ProjectPath}; |
| 19 | use schemars::JsonSchema; | 23 | use schemars::JsonSchema; |
| 20 | use serde::{Deserialize, Serialize}; | 24 | use serde::{Deserialize, Serialize}; |
| 21 | use std::ops::Range; | | |
| 22 | use std::path::PathBuf; | 25 | use std::path::PathBuf; |
| 23 | use std::sync::Arc; | 26 | use std::sync::Arc; |
| 24 | use text::{BufferSnapshot, ToOffset as _}; | 27 | use streaming_diff::{CharOperation, StreamingDiff}; |
| 25 | use ui::SharedString; | 28 | use ui::SharedString; |
| 26 | use util::rel_path::RelPath; | 29 | use util::rel_path::RelPath; |
| 27 | use util::{Deferred, ResultExt, debug_panic}; | 30 | use util::{Deferred, ResultExt}; |
| 28 | | 31 | |
| 29 | const DEFAULT_UI_TEXT: &str = "Editing file"; | 32 | const DEFAULT_UI_TEXT: &str = "Editing file"; |
| 30 | | 33 | |
| @@ -70,14 +73,13 @@ pub struct StreamingEditFileToolInput { · 39 unmodified lines |
| 70 | pub path: String, | 73 | pub path: String, |
| 71 | | 74 | |
| 72 | /// The mode of operation on the file. Possible values: | 75 | /// The mode of operation on the file. Possible values: |
| 73 | /// - 'create': Create a new file if it doesn't exist. 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. |
| 74 | /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field. | | |
| 75 | /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. | 77 | /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. |
| 76 | /// | 78 | /// |
| 77 | /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. | 79 | /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. |
| 78 | pub mode: StreamingEditFileMode, | 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 | /// This field should contain the entire file content. | 83 | /// This field should contain the entire file content. |
| 82 | #[serde(default, skip_serializing_if = "Option::is_none")] | 84 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 83 | pub content: Option<String>, | 85 | pub content: Option<String>, |
| @@ -85,23 +87,22 @@ pub struct StreamingEditFileToolInput { · 1 unmodified line |
| 85 | /// List of edit operations to apply sequentially (required for 'edit' mode). | 87 | /// List of edit operations to apply sequentially (required for 'edit' mode). |
| 86 | /// Each edit finds `old_text` in the file and replaces it with `new_text`. | 88 | /// Each edit finds `old_text` in the file and replaces it with `new_text`. |
| 87 | #[serde(default, skip_serializing_if = "Option::is_none")] | 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 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] | 93 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] |
| 92 | #[serde(rename_all = "snake_case")] | 94 | #[serde(rename_all = "snake_case")] |
| 93 | pub enum StreamingEditFileMode { | 95 | pub enum StreamingEditFileMode { |
| 94 | /// Create a new file if it doesn't exist | 96 | /// Overwrite the file with new content (replacing any existing content). |
| 95 | Create, | 97 | /// If the file does not exist, it will be created. |
| 96 | /// Replace the entire contents of an existing file | 98 | Write, |
| 97 | Overwrite, | | |
| 98 | /// Make granular edits to an existing file | 99 | /// Make granular edits to an existing file |
| 99 | Edit, | 100 | Edit, |
| 100 | } | 101 | } |
| 101 | | 102 | |
| 102 | /// A single edit operation that replaces old text with new text | 103 | /// A single edit operation that replaces old text with new text |
| 103 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] | 104 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] |
| 104 | pub struct EditOperation { | 105 | pub struct Edit { |
| 105 | /// The exact text to find in the file. This will be matched using fuzzy matching | 106 | /// The exact text to find in the file. This will be matched using fuzzy matching |
| 106 | /// to handle minor differences in whitespace or formatting. | 107 | /// to handle minor differences in whitespace or formatting. |
| 107 | pub old_text: String, | 108 | pub old_text: String, |
| @@ -118,271 +119,328 @@ struct StreamingEditFileToolPartialInput { · 10 unmodified lines |
| 118 | #[serde(default)] | 119 | #[serde(default)] |
| 119 | mode: Option<StreamingEditFileMode>, | 120 | mode: Option<StreamingEditFileMode>, |
| 120 | #[serde(default)] | 121 | #[serde(default)] |
| 121 | #[allow(dead_code)] | | |
| 122 | content: Option<String>, | 122 | content: Option<String>, |
| 123 | #[serde(default)] | 123 | #[serde(default)] |
| 124 | edits: Option<Vec<PartialEditOperation>>, | 124 | edits: Option<Vec<PartialEdit>>, |
| 125 | } | 125 | } |
| 126 | | 126 | |
| 127 | #[derive(Default, Debug, Deserialize)] | 127 | #[derive(Default, Debug, Deserialize)] |
| 128 | struct PartialEditOperation { | 128 | pub struct PartialEdit { |
| 129 | #[serde(default)] | 129 | #[serde(default)] |
| 130 | old_text: Option<String>, | 130 | pub old_text: Option<String>, |
| 131 | #[serde(default)] | 131 | #[serde(default)] |
| 132 | new_text: Option<String>, | 132 | pub new_text: Option<String>, |
| 133 | } | 133 | } |
| 134 | | 134 | |
| 135 | enum StreamingEditState { | 135 | #[derive(Debug, Serialize, Deserialize)] |
| 136 | Idle, | 136 | #[serde(untagged)] |
| 137 | BufferResolved { | 137 | pub enum StreamingEditFileToolOutput { |
| 138 | abs_path: PathBuf, | 138 | Success { |
| 139 | buffer: Entity<Buffer>, | 139 | #[serde(alias = "original_path")] |
| | 140 | input_path: PathBuf, |
| | 141 | new_text: String, |
| 140 | old_text: Arc<String>, | 142 | old_text: Arc<String>, |
| 141 | diff: Entity<Diff>, | 143 | #[serde(default)] |
| 142 | mode: StreamingEditFileMode, | 144 | diff: String, |
| 143 | last_content_len: usize, | 145 | }, |
| 144 | edit_state: IncrementalEditState, | 146 | Error { |
| 145 | _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>, | 147 | error: String, |
| 146 | }, | 148 | }, |
| 147 | } | 149 | } |
| 148 | | 150 | |
| 149 | #[derive(Default)] | 151 | impl StreamingEditFileToolOutput { |
| 150 | struct IncrementalEditState { | 152 | pub fn error(error: impl Into<String>) -> Self { |
| 151 | in_progress_matcher: Option<StreamingFuzzyMatcher>, | 153 | Self::Error { |
| 152 | last_old_text_len: usize, | 154 | error: error.into(), |
| 153 | applied_ranges: Vec<Range<text::Anchor>>, | 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 { | 180 | impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent { |
| 157 | fn applied_count(&self) -> usize { | 181 | fn from(output: StreamingEditFileToolOutput) -> Self { |
| 158 | self.applied_ranges.len() | 182 | output.to_string().into() |
| 159 | } | 183 | } |
| 160 | } | 184 | } |
| 161 | | 185 | |
| 162 | impl StreamingEditState { | 186 | pub struct StreamingEditFileTool { |
| 163 | async fn finalize( | 187 | thread: WeakEntity<Thread>, |
| 164 | &mut self, | 188 | language_registry: Arc<LanguageRegistry>, |
| 165 | input: StreamingEditFileToolInput, | 189 | project: Entity<Project>, |
| 166 | tool: &StreamingEditFileTool, | 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 | event_stream: &ToolCallEventStream, | 209 | event_stream: &ToolCallEventStream, |
| 168 | cx: &mut AsyncApp, | 210 | cx: &mut App, |
| 169 | ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> { | 211 | ) -> Task<Result<()>> { |
| 170 | let remaining_edits_start_ix = match self { | 212 | super::tool_permissions::authorize_file_edit( |
| 171 | StreamingEditState::Idle => { | 213 | EditFileTool::NAME, |
| 172 | *self = Self::transition_to_buffer_resolved( | 214 | path, |
| 173 | &input.path, | 215 | description, |
| 174 | &input.display_description, | 216 | &self.thread, |
| 175 | input.mode.clone(), | 217 | event_stream, |
| 176 | tool, | 218 | cx, |
| 177 | event_stream, | 219 | ) |
| 178 | cx, | 220 | } |
| 179 | ) | | |
| 180 | .await?; | | |
| 181 | 0 | | |
| 182 | } | | |
| 183 | StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count(), | | |
| 184 | }; | | |
| 185 | | 221 | |
| 186 | let StreamingEditState::BufferResolved { | 222 | fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) { |
| 187 | buffer, | 223 | self.project.update(cx, |project, cx| { |
| 188 | old_text, | 224 | project.set_agent_location(Some(AgentLocation { buffer, position }), cx); |
| 189 | diff, | 225 | }); |
| 190 | abs_path, | 226 | } |
| 191 | .. | 227 | } |
| 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 | }; | | |
| 199 | | 228 | |
| 200 | let result: anyhow::Result<StreamingEditFileToolOutput> = async { | 229 | impl AgentTool for StreamingEditFileTool { |
| 201 | let action_log = tool | 230 | type Input = StreamingEditFileToolInput; |
| 202 | .thread | 231 | type Output = StreamingEditFileToolOutput; |
| 203 | .read_with(cx, |thread, _cx| thread.action_log().clone())?; | | |
| 204 | | 232 | |
| 205 | match input.mode { | 233 | const NAME: &'static str = "streaming_edit_file"; |
| 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 | } | | |
| 239 | | 234 | |
| 240 | let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { | 235 | fn supports_input_streaming() -> bool { |
| 241 | let settings = language_settings::language_settings( | 236 | true |
| 242 | buffer.language().map(|l| l.name()), | 237 | } |
| 243 | buffer.file(), | | |
| 244 | cx, | | |
| 245 | ); | | |
| 246 | settings.format_on_save != FormatOnSave::Off | | |
| 247 | }); | | |
| 248 | | 238 | |
| 249 | if format_on_save_enabled { | 239 | fn kind() -> acp::ToolKind { |
| 250 | action_log.update(cx, |log, cx| { | 240 | acp::ToolKind::Edit |
| 251 | log.buffer_edited(buffer.clone(), cx); | 241 | } |
| 252 | }); | | |
| 253 | | 242 | |
| 254 | let format_task = tool.project.update(cx, |project, cx| { | 243 | fn initial_title( |
| 255 | project.format( | 244 | &self, |
| 256 | HashSet::from_iter([buffer.clone()]), | 245 | input: Result<Self::Input, serde_json::Value>, |
| 257 | LspFormatTarget::Buffers, | 246 | cx: &mut App, |
| 258 | false, | 247 | ) -> SharedString { |
| 259 | FormatTrigger::Save, | 248 | match input { |
| 260 | cx, | 249 | Ok(input) => self |
| 261 | ) | 250 | .project |
| 262 | }); | 251 | .read(cx) |
| 263 | futures::select! { | 252 | .find_project_path(&input.path, cx) |
| 264 | result = format_task.fuse() => { result.log_err(); }, | 253 | .and_then(|project_path| { |
| 265 | _ = event_stream.cancelled_by_user().fuse() => { | 254 | self.project |
| 266 | anyhow::bail!("Edit cancelled by user"); | 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 | 280 | let description = input.display_description.unwrap_or_default(); |
| 272 | .project | 281 | let description = description.trim(); |
| 273 | .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); | 282 | if !description.is_empty() { |
| 274 | futures::select! { | 283 | return description.to_string().into(); |
| 275 | result = save_task.fuse() => { result?; }, | 284 | } |
| 276 | _ = event_stream.cancelled_by_user().fuse() => { | | |
| 277 | anyhow::bail!("Edit cancelled by user"); | | |
| 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, _| { | 287 | DEFAULT_UI_TEXT.into() |
| 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 | })?; | | |
| 293 | } | 288 | } |
| | 289 | } |
| | 290 | } |
| 294 | | 291 | |
| 295 | let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); | 292 | fn run( |
| 296 | let (new_text, unified_diff) = cx | 293 | self: Arc<Self>, |
| 297 | .background_spawn({ | 294 | mut input: ToolInput<Self::Input>, |
| 298 | let new_snapshot = new_snapshot.clone(); | 295 | event_stream: ToolCallEventStream, |
| 299 | let old_text = old_text.clone(); | 296 | cx: &mut App, |
| 300 | async move { | 297 | ) -> Task<Result<Self::Output, Self::Output>> { |
| 301 | let new_text = new_snapshot.text(); | 298 | cx.spawn(async move |cx: &mut AsyncApp| { |
| 302 | let diff = language::unified_diff(&old_text, &new_text); | 299 | let mut state: Option<EditSession> = None; |
| 303 | (new_text, diff) | 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 | }) | 326 | _ = event_stream.cancelled_by_user().fuse() => { |
| 306 | .await; | 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 { | 337 | let mut state = if let Some(state) = state { |
| 309 | input_path: PathBuf::from(input.path), | 338 | state |
| 310 | new_text, | 339 | } else { |
| 311 | old_text: old_text.clone(), | 340 | EditSession::new( |
| 312 | diff: unified_diff, | 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) | 350 | state.finalize(full_input, &self, &event_stream, cx).await |
| 315 | } | | |
| 316 | .await; | | |
| 317 | result.map_err(|e| StreamingEditFileToolOutput::Error { | | |
| 318 | error: e.to_string(), | | |
| 319 | }) | 351 | }) |
| 320 | } | 352 | } |
| 321 | | 353 | |
| 322 | async fn process( | 354 | fn replay( |
| 323 | &mut self, | 355 | &self, |
| 324 | partial: StreamingEditFileToolPartialInput, | 356 | _input: Self::Input, |
| 325 | tool: &StreamingEditFileTool, | 357 | output: Self::Output, |
| 326 | event_stream: &ToolCallEventStream, | 358 | event_stream: ToolCallEventStream, |
| 327 | cx: &mut AsyncApp, | 359 | cx: &mut App, |
| 328 | ) -> Result<(), StreamingEditFileToolOutput> { | 360 | ) -> Result<()> { |
| 329 | match self { | 361 | match output { |
| 330 | Self::Idle => { | 362 | StreamingEditFileToolOutput::Success { |
| 331 | if let Some(path_str) = partial.path | 363 | input_path, |
| 332 | && let Some(display_description) = partial.display_description | 364 | old_text, |
| 333 | && let Some(mode) = partial.mode | 365 | new_text, |
| 334 | { | 366 | .. |
| 335 | *self = Self::transition_to_buffer_resolved( | 367 | } => { |
| 336 | &path_str, | 368 | event_stream.update_diff(cx.new(|cx| { |
| 337 | &display_description, | 369 | Diff::finalized( |
| 338 | mode, | 370 | input_path.to_string_lossy().into_owned(), |
| 339 | tool, | 371 | Some(old_text.to_string()), |
| 340 | event_stream, | 372 | new_text, |
| | 373 | self.language_registry.clone(), |
| 341 | cx, | 374 | cx, |
| 342 | ) | 375 | ) |
| 343 | .await?; | 376 | })); |
| 344 | } | 377 | Ok(()) |
| 345 | } | 378 | } |
| 346 | Self::BufferResolved { | 379 | StreamingEditFileToolOutput::Error { .. } => Ok(()), |
| 347 | abs_path, | 380 | } |
| 348 | buffer, | 381 | } |
| 349 | edit_state, | 382 | } |
| 350 | diff, | 383 | |
| 351 | mode, | 384 | pub struct EditSession { |
| 352 | last_content_len, | 385 | abs_path: PathBuf, |
| 353 | .. | 386 | buffer: Entity<Buffer>, |
| 354 | } => match mode { | 387 | old_text: Arc<String>, |
| 355 | StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => { | 388 | diff: Entity<Diff>, |
| 356 | if let Some(content) = &partial.content { | 389 | mode: StreamingEditFileMode, |
| 357 | Self::process_streaming_content( | 390 | parser: ToolEditParser, |
| 358 | buffer, | 391 | pipeline: EditPipeline, |
| 359 | diff, | 392 | _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>, |
| 360 | last_content_len, | 393 | } |
| 361 | content, | 394 | |
| 362 | cx, | 395 | struct EditPipeline { |
| 363 | )?; | 396 | edits: Vec<EditPipelineEntry>, |
| 364 | } | 397 | content_written: bool, |
| 365 | } | 398 | } |
| 366 | StreamingEditFileMode::Edit => { | 399 | |
| 367 | if let Some(edits) = partial.edits { | 400 | enum EditPipelineEntry { |
| 368 | Self::process_streaming_edits( | 401 | ResolvingOldText { |
| 369 | buffer, | 402 | matcher: StreamingFuzzyMatcher, |
| 370 | diff, | 403 | }, |
| 371 | edit_state, | 404 | StreamingNewText { |
| 372 | &edits, | 405 | streaming_diff: StreamingDiff, |
| 373 | abs_path, | 406 | edit_cursor: usize, |
| 374 | tool, | 407 | reindenter: Reindenter, |
| 375 | event_stream, | 408 | original_snapshot: text::BufferSnapshot, |
| 376 | cx, | 409 | }, |
| 377 | )?; | 410 | Done, |
| 378 | } | 411 | } |
| 379 | } | 412 | |
| 380 | }, | 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 | path_str: &str, | 444 | path_str: &str, |
| 387 | display_description: &str, | 445 | display_description: &str, |
| 388 | mode: StreamingEditFileMode, | 446 | mode: StreamingEditFileMode, |
| @@ -393,15 +451,13 @@ impl StreamingEditState { · 4 unmodified lines |
| 393 | let path = PathBuf::from(path_str); | 451 | let path = PathBuf::from(path_str); |
| 394 | let project_path = cx | 452 | let project_path = cx |
| 395 | .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx)) | 453 | .update(|cx| resolve_path(mode.clone(), &path, &tool.project, cx)) |
| 396 | .map_err(|e| StreamingEditFileToolOutput::Error { | 454 | .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 397 | error: e.to_string(), | | |
| 398 | })?; | | |
| 399 | | 455 | |
| 400 | let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) | 456 | let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) |
| 401 | else { | 457 | else { |
| 402 | return Err(StreamingEditFileToolOutput::Error { | 458 | return Err(StreamingEditFileToolOutput::error(format!( |
| 403 | error: format!("File '{path_str}' does not exist"), | 459 | "Worktree at '{path_str}' does not exist" |
| 404 | }); | 460 | ))); |
| 405 | }; | 461 | }; |
| 406 | | 462 | |
| 407 | event_stream.update_fields( | 463 | event_stream.update_fields( |
| @@ -410,17 +466,13 @@ impl StreamingEditState { · 2 unmodified lines |
| 410 | | 466 | |
| 411 | cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) | 467 | cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) |
| 412 | .await | 468 | .await |
| 413 | .map_err(|e| StreamingEditFileToolOutput::Error { | 469 | .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 414 | error: e.to_string(), | | |
| 415 | })?; | | |
| 416 | | 470 | |
| 417 | let buffer = tool | 471 | let buffer = tool |
| 418 | .project | 472 | .project |
| 419 | .update(cx, |project, cx| project.open_buffer(project_path, cx)) | 473 | .update(cx, |project, cx| project.open_buffer(project_path, cx)) |
| 420 | .await | 474 | .await |
| 421 | .map_err(|e| StreamingEditFileToolOutput::Error { | 475 | .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 422 | error: e.to_string(), | | |
| 423 | })?; | | |
| 424 | | 476 | |
| 425 | ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; | 477 | ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; |
| 426 | | 478 | |
| @@ -434,6 +486,14 @@ impl StreamingEditState { · 7 unmodified lines |
| 434 | } | 486 | } |
| 435 | }) as Box<dyn FnOnce()>); | 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 | let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); | 497 | let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 438 | let old_text = cx | 498 | let old_text = cx |
| 439 | .background_spawn({ | 499 | .background_spawn({ |
| @@ -442,205 +502,497 @@ impl StreamingEditState { · 2 unmodified lines |
| 442 | }) | 502 | }) |
| 443 | .await; | 503 | .await; |
| 444 | | 504 | |
| 445 | Ok(Self::BufferResolved { | 505 | Ok(Self { |
| 446 | abs_path, | 506 | abs_path, |
| 447 | buffer, | 507 | buffer, |
| 448 | old_text, | 508 | old_text, |
| 449 | diff, | 509 | diff, |
| 450 | mode, | 510 | mode, |
| 451 | last_content_len: 0, | 511 | parser: ToolEditParser::default(), |
| 452 | edit_state: IncrementalEditState::default(), | 512 | pipeline: EditPipeline::new(), |
| 453 | _finalize_diff_guard: finalize_diff_guard, | 513 | _finalize_diff_guard: finalize_diff_guard, |
| 454 | }) | 514 | }) |
| 455 | } | 515 | } |
| 456 | | 516 | |
| 457 | fn process_streaming_content( | 517 | async fn finalize( |
| 458 | buffer: &Entity<Buffer>, | 518 | &mut self, |
| 459 | diff: &Entity<Diff>, | 519 | input: StreamingEditFileToolInput, |
| 460 | last_content_len: &mut usize, | 520 | tool: &StreamingEditFileTool, |
| 461 | content: &str, | 521 | event_stream: &ToolCallEventStream, |
| 462 | cx: &mut AsyncApp, | 522 | cx: &mut AsyncApp, |
| 463 | ) -> Result<(), StreamingEditFileToolOutput> { | 523 | ) -> Result<StreamingEditFileToolOutput, StreamingEditFileToolOutput> { |
| 464 | let new_len = content.len(); | 524 | let Self { |
| 465 | if new_len > *last_content_len { | 525 | buffer, |
| 466 | let new_chunk = &content[*last_content_len..]; | 526 | old_text, |
| 467 | cx.update(|cx| { | 527 | diff, |
| 468 | buffer.update(cx, |buffer, cx| { | 528 | abs_path, |
| 469 | // On the first update, replace the entire buffer (handles Overwrite | 529 | parser, |
| 470 | // clearing existing content). For Create the buffer is already empty | 530 | pipeline, |
| 471 | // so 0..0 is a no-op range prefix. | 531 | .. |
| 472 | let insert_at = if *last_content_len == 0 { | 532 | } = self; |
| 473 | 0..buffer.len() | 533 | |
| 474 | } else { | 534 | let action_log = tool |
| 475 | let len = buffer.len(); | 535 | .thread |
| 476 | len..len | 536 | .read_with(cx, |thread, _cx| thread.action_log().clone()) |
| 477 | }; | 537 | .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; |
| 478 | buffer.edit([(insert_at, new_chunk)], None, cx); | 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| { | 600 | let format_task = tool.project.update(cx, |project, cx| { |
| 484 | buffer.anchor_range_between(0..buffer.len()) | 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 | Ok(()) | 704 | Ok(()) |
| 489 | } | 705 | } |
| 490 | | 706 | |
| 491 | fn process_streaming_edits( | 707 | fn process_events( |
| | 708 | events: &[ToolEditEvent], |
| 492 | buffer: &Entity<Buffer>, | 709 | buffer: &Entity<Buffer>, |
| 493 | diff: &Entity<Diff>, | 710 | diff: &Entity<Diff>, |
| 494 | edit_state: &mut IncrementalEditState, | 711 | pipeline: &mut EditPipeline, |
| 495 | edits: &[PartialEditOperation], | | |
| 496 | abs_path: &PathBuf, | 712 | abs_path: &PathBuf, |
| 497 | tool: &StreamingEditFileTool, | 713 | tool: &StreamingEditFileTool, |
| 498 | event_stream: &ToolCallEventStream, | 714 | event_stream: &ToolCallEventStream, |
| 499 | cx: &mut AsyncApp, | 715 | cx: &mut AsyncApp, |
| 500 | ) -> Result<(), StreamingEditFileToolOutput> { | 716 | ) -> Result<(), StreamingEditFileToolOutput> { |
| 501 | if edits.is_empty() { | 717 | for event in events { |
| 502 | return Ok(()); | 718 | match event { |
| 503 | } | 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 | 740 | ToolEditEvent::OldTextChunk { |
| 506 | // (a subsequent edit exists, proving the LLM moved on). | 741 | edit_index, |
| 507 | // The last edit (edits.len()-1) is potentially still in progress. | 742 | chunk, |
| 508 | let completed_count = edits.len().saturating_sub(1); | 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 | 766 | ToolEditEvent::OldTextChunk { |
| 511 | while edit_state.applied_count() < completed_count { | 767 | edit_index, |
| 512 | let edit_index = edit_state.applied_count(); | 768 | chunk, |
| 513 | let partial_edit = &edits[edit_index]; | 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(|| { | 779 | if !chunk.is_empty() { |
| 516 | StreamingEditFileToolOutput::Error { | 780 | matcher.push(chunk, None); |
| 517 | error: format!("Edit at index {} is missing old_text.", edit_index), | 781 | } |
| 518 | } | 782 | let matches = matcher.finish(); |
| 519 | })?; | 783 | |
| 520 | let new_text = partial_edit.new_text.clone().unwrap_or_default(); | 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; | 807 | let range = matches.into_iter().next().expect("checked len above"); |
| 523 | edit_state.last_old_text_len = 0; | | |
| 524 | | 808 | |
| 525 | let edit_op = EditOperation { | 809 | let anchor_range = buffer |
| 526 | old_text: old_text.clone(), | 810 | .read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); |
| 527 | new_text: new_text.clone(), | 811 | diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); |
| 528 | }; | 812 | |
| | 813 | let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); |
| 529 | | 814 | |
| 530 | let action_log = tool | 815 | let line = snapshot.offset_to_point(range.start).row; |
| 531 | .thread | 816 | event_stream.update_fields( |
| 532 | .read_with(cx, |thread, _cx| thread.action_log().clone()) | 817 | ToolCallUpdateFields::new() |
| 533 | .ok(); | 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 | 842 | cx.update(|cx| { |
| 536 | if edit_state.applied_count() == 0 { | 843 | let position = buffer.read(cx).anchor_before(range.end); |
| 537 | if let Some(action_log) = &action_log { | 844 | tool.set_agent_location(buffer.downgrade(), position, cx); |
| 538 | action_log.update(cx, |log, cx| { | | |
| 539 | log.buffer_read(buffer.clone(), cx); | | |
| 540 | }); | 845 | }); |
| 541 | } | 846 | } |
| 542 | } | | |
| 543 | | 847 | |
| 544 | let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); | 848 | ToolEditEvent::NewTextChunk { |
| 545 | | 849 | edit_index, |
| 546 | let (range, new_text) = | 850 | chunk, |
| 547 | match resolve_and_reveal_edit(buffer, diff, &snapshot, &edit_op, cx) { | 851 | done: false, |
| 548 | Ok(resolved) => resolved, | 852 | } => { |
| 549 | Err(EditResolveError::NotFound) => { | 853 | if *edit_index >= pipeline.edits.len() { |
| 550 | return Err(StreamingEditFileToolOutput::Error { | 854 | continue; |
| 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 | }); | | |
| 558 | } | 855 | } |
| 559 | Err(EditResolveError::Ambiguous(ranges)) => { | 856 | let EditPipelineEntry::StreamingNewText { |
| 560 | let lines = ranges | 857 | streaming_diff, |
| 561 | .iter() | 858 | edit_cursor, |
| 562 | .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) | 859 | reindenter, |
| 563 | .collect::<Vec<_>>() | 860 | original_snapshot, |
| 564 | .join(", "); | 861 | .. |
| 565 | return Err(StreamingEditFileToolOutput::Error { | 862 | } = &mut pipeline.edits[*edit_index] |
| 566 | error: format!( | 863 | else { |
| 567 | "Edit {} matched multiple locations in the file at lines: {}. \ | 864 | continue; |
| 568 | Please provide more context in old_text to uniquely \ | 865 | }; |
| 569 | identify the location.", | 866 | |
| 570 | edit_index, lines | 867 | let reindented = reindenter.push(chunk); |
| 571 | ), | 868 | if reindented.is_empty() { |
| 572 | }); | 869 | continue; |
| 573 | } | 870 | } |
| 574 | }; | | |
| 575 | | 871 | |
| 576 | for previous_range in &edit_state.applied_ranges { | 872 | let char_ops = streaming_diff.push_new(&reindented); |
| 577 | let previous_start = previous_range.start.to_offset(&snapshot); | 873 | Self::apply_char_operations( |
| 578 | let previous_end = previous_range.end.to_offset(&snapshot); | 874 | &char_ops, |
| 579 | if range.start < previous_end && previous_start < range.end { | 875 | buffer, |
| 580 | let earlier_start_line = snapshot.offset_to_point(previous_start).row + 1; | 876 | original_snapshot, |
| 581 | let earlier_end_line = snapshot.offset_to_point(previous_end).row + 1; | 877 | edit_cursor, |
| 582 | let later_start_line = snapshot.offset_to_point(range.start).row + 1; | 878 | cx, |
| 583 | let later_end_line = snapshot.offset_to_point(range.end).row + 1; | 879 | ); |
| 584 | return Err(StreamingEditFileToolOutput::Error { | 880 | |
| 585 | error: format!( | 881 | let position = original_snapshot.anchor_before(*edit_cursor); |
| 586 | "Conflicting edit ranges detected: lines {}-{} \ | 882 | cx.update(|cx| { |
| 587 | conflicts with lines {}-{}. Conflicting edit \ | 883 | tool.set_agent_location(buffer.downgrade(), position, cx); |
| 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 | ), | | |
| 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 = | 897 | ToolEditEvent::NewTextChunk { |
| 597 | buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); | 898 | edit_index, |
| 598 | edit_state.applied_ranges.push(anchor_range); | 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; | 906 | let EditPipelineEntry::StreamingNewText { |
| 601 | event_stream.update_fields( | 907 | mut streaming_diff, |
| 602 | ToolCallUpdateFields::new() | 908 | mut edit_cursor, |
| 603 | .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), | 909 | mut reindenter, |
| 604 | ); | 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 { | 919 | // Flush any remaining reindent buffer + final chunk. |
| 607 | cx.update(|cx| { | 920 | let mut final_text = reindenter.push(chunk); |
| 608 | buffer.update(cx, |buffer, cx| { | 921 | final_text.push_str(&reindenter.finish()); |
| 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 | } | | |
| 617 | | 922 | |
| 618 | // Feed the in-progress last edit's old_text to the matcher for live preview | 923 | if !final_text.is_empty() { |
| 619 | if let Some(partial_edit) = edits.last() { | 924 | let char_ops = streaming_diff.push_new(&final_text); |
| 620 | if let Some(old_text) = &partial_edit.old_text { | 925 | Self::apply_char_operations( |
| 621 | let old_text_len = old_text.len(); | 926 | &char_ops, |
| 622 | if old_text_len > edit_state.last_old_text_len { | 927 | buffer, |
| 623 | let new_chunk = &old_text[edit_state.last_old_text_len..]; | 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(|| { | 943 | let position = original_snapshot.anchor_before(edit_cursor); |
| 626 | let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); | 944 | cx.update(|cx| { |
| 627 | StreamingFuzzyMatcher::new(snapshot) | 945 | tool.set_agent_location(buffer.downgrade(), position, cx); |
| 628 | }); | 946 | }); |
| 629 | | 947 | |
| 630 | if let Some(match_range) = matcher.push(new_chunk, None) { | 948 | let action_log = tool |
| 631 | let anchor_range = buffer.read_with(cx, |buffer, _cx| { | 949 | .thread |
| 632 | buffer.anchor_range_between(match_range.clone()) | 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 | Ok(()) | 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 | fn ensure_buffer_saved( | 998 | fn ensure_buffer_saved( |
| @@ -670,396 +1022,40 @@ fn ensure_buffer_saved( · 23 unmodified lines |
| 670 | if is_dirty { | 1022 | if is_dirty { |
| 671 | let message = match (has_save_tool, has_restore_tool) { | 1023 | let message = match (has_save_tool, has_restore_tool) { |
| 672 | (true, true) => { | 1024 | (true, true) => { |
| 673 | "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ | 1025 | "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. \ | 1026 | 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." | 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." |
| 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)); | | |
| 936 | } | 1028 | } |
| 937 | Err(EditResolveError::NotFound) => { | 1029 | (true, false) => { |
| 938 | failed_edits.push(index); | 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)) => { | 1034 | (false, true) => { |
| 941 | ambiguous_edits.push((index, ranges)); | 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 | } | 1039 | (false, false) => { |
| 944 | } | 1040 | "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ |
| 945 | | 1041 | then ask them to save or revert the file manually and inform you when it's ok to proceed." |
| 946 | if !failed_edits.is_empty() { | 1042 | } |
| 947 | let indices = failed_edits | 1043 | }; |
| 948 | .iter() | 1044 | return Err(StreamingEditFileToolOutput::error(message)); |
| 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 | ); | | |
| 988 | } | 1045 | } |
| 989 | | 1046 | |
| 990 | for window in edits_sorted.windows(2) { | 1047 | if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { |
| 991 | if let [(earlier_range, _), (later_range, _)] = window | 1048 | if current != last_read { |
| 992 | && (earlier_range.end > later_range.start || earlier_range.start == later_range.start) | 1049 | return Err(StreamingEditFileToolOutput::error( |
| 993 | { | 1050 | "The file has been modified since you last read it. \ |
| 994 | let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1; | 1051 | Please read the file again to get the current state before editing it.", |
| 995 | let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1; | 1052 | )); |
| 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 | ); | | |
| 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 | Ok(()) | 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 | fn resolve_path( | 1059 | fn resolve_path( |
| 1064 | mode: StreamingEditFileMode, | 1060 | mode: StreamingEditFileMode, |
| 1065 | path: &PathBuf, | 1061 | path: &PathBuf, |
| @@ -1069,7 +1065,7 @@ fn resolve_path( · 3 unmodified lines |
| 1069 | let project = project.read(cx); | 1065 | let project = project.read(cx); |
| 1070 | | 1066 | |
| 1071 | match mode { | 1067 | match mode { |
| 1072 | StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => { | 1068 | StreamingEditFileMode::Edit => { |
| 1073 | let path = project | 1069 | let path = project |
| 1074 | .find_project_path(&path, cx) | 1070 | .find_project_path(&path, cx) |
| 1075 | .context("Can't edit file: path not found")?; | 1071 | .context("Can't edit file: path not found")?; |
| @@ -1081,13 +1077,12 @@ fn resolve_path( · 5 unmodified lines |
| 1081 | anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); | 1077 | anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); |
| 1082 | Ok(path) | 1078 | Ok(path) |
| 1083 | } | 1079 | } |
| 1084 | | 1080 | StreamingEditFileMode::Write => { |
| 1085 | StreamingEditFileMode::Create => { | 1081 | if let Some(path) = project.find_project_path(&path, cx) |
| 1086 | if let Some(path) = project.find_project_path(&path, cx) { | 1082 | && let Some(entry) = project.entry_for_path(&path, cx) |
| 1087 | anyhow::ensure!( | 1083 | { |
| 1088 | project.entry_for_path(&path, cx).is_none(), | 1084 | anyhow::ensure!(entry.is_file(), "Can't write to file: path is a directory"); |
| 1089 | "Can't create file: file already exists" | 1085 | return Ok(path); |
| 1090 | ); | | |
| 1091 | } | 1086 | } |
| 1092 | | 1087 | |
| 1093 | let parent_path = path.parent().context("Can't create file: incorrect path")?; | 1088 | let parent_path = path.parent().context("Can't create file: incorrect path")?; |
| @@ -1162,7 +1157,7 @@ mod tests { · 68 unmodified lines |
| 1162 | let input = StreamingEditFileToolInput { | 1157 | let input = StreamingEditFileToolInput { |
| 1163 | display_description: "Create new file".into(), | 1158 | display_description: "Create new file".into(), |
| 1164 | path: "root/dir/new_file.txt".into(), | 1159 | path: "root/dir/new_file.txt".into(), |
| 1165 | mode: StreamingEditFileMode::Create, | 1160 | mode: StreamingEditFileMode::Write, |
| 1166 | content: Some("Hello, World!".into()), | 1161 | content: Some("Hello, World!".into()), |
| 1167 | edits: None, | 1162 | edits: None, |
| 1168 | }; | 1163 | }; |
| @@ -1214,7 +1209,7 @@ mod tests { · 45 unmodified lines |
| 1214 | let input = StreamingEditFileToolInput { | 1209 | let input = StreamingEditFileToolInput { |
| 1215 | display_description: "Overwrite file".into(), | 1210 | display_description: "Overwrite file".into(), |
| 1216 | path: "root/file.txt".into(), | 1211 | path: "root/file.txt".into(), |
| 1217 | mode: StreamingEditFileMode::Overwrite, | 1212 | mode: StreamingEditFileMode::Write, |
| 1218 | content: Some("new content".into()), | 1213 | content: Some("new content".into()), |
| 1219 | edits: None, | 1214 | edits: None, |
| 1220 | }; | 1215 | }; |
| @@ -1276,7 +1271,7 @@ mod tests { · 55 unmodified lines |
| 1276 | path: "root/file.txt".into(), | 1271 | path: "root/file.txt".into(), |
| 1277 | mode: StreamingEditFileMode::Edit, | 1272 | mode: StreamingEditFileMode::Edit, |
| 1278 | content: None, | 1273 | content: None, |
| 1279 | edits: Some(vec![EditOperation { | 1274 | edits: Some(vec![Edit { |
| 1280 | old_text: "line 2".into(), | 1275 | old_text: "line 2".into(), |
| 1281 | new_text: "modified line 2".into(), | 1276 | new_text: "modified line 2".into(), |
| 1282 | }]), | 1277 | }]), |
| @@ -1301,7 +1296,7 @@ mod tests { · 18 unmodified lines |
| 1301 | } | 1296 | } |
| 1302 | | 1297 | |
| 1303 | #[gpui::test] | 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 | init_test(cx); | 1300 | init_test(cx); |
| 1306 | | 1301 | |
| 1307 | let fs = project::FakeFs::new(cx.executor()); | 1302 | let fs = project::FakeFs::new(cx.executor()); |
| @@ -1336,11 +1331,11 @@ mod tests { · 28 unmodified lines |
| 1336 | mode: StreamingEditFileMode::Edit, | 1331 | mode: StreamingEditFileMode::Edit, |
| 1337 | content: None, | 1332 | content: None, |
| 1338 | edits: Some(vec![ | 1333 | edits: Some(vec![ |
| 1339 | EditOperation { | 1334 | Edit { |
| 1340 | old_text: "line 5".into(), | 1335 | old_text: "line 5".into(), |
| 1341 | new_text: "modified line 5".into(), | 1336 | new_text: "modified line 5".into(), |
| 1342 | }, | 1337 | }, |
| 1343 | EditOperation { | 1338 | Edit { |
| 1344 | old_text: "line 1".into(), | 1339 | old_text: "line 1".into(), |
| 1345 | new_text: "modified line 1".into(), | 1340 | new_text: "modified line 1".into(), |
| 1346 | }, | 1341 | }, |
| @@ -1404,11 +1399,11 @@ mod tests { · 57 unmodified lines |
| 1404 | mode: StreamingEditFileMode::Edit, | 1399 | mode: StreamingEditFileMode::Edit, |
| 1405 | content: None, | 1400 | content: None, |
| 1406 | edits: Some(vec![ | 1401 | edits: Some(vec![ |
| 1407 | EditOperation { | 1402 | Edit { |
| 1408 | old_text: "line 2".into(), | 1403 | old_text: "line 2".into(), |
| 1409 | new_text: "modified line 2".into(), | 1404 | new_text: "modified line 2".into(), |
| 1410 | }, | 1405 | }, |
| 1411 | EditOperation { | 1406 | Edit { |
| 1412 | old_text: "line 3".into(), | 1407 | old_text: "line 3".into(), |
| 1413 | new_text: "modified line 3".into(), | 1408 | new_text: "modified line 3".into(), |
| 1414 | }, | 1409 | }, |
| @@ -1472,11 +1467,11 @@ mod tests { · 57 unmodified lines |
| 1472 | mode: StreamingEditFileMode::Edit, | 1467 | mode: StreamingEditFileMode::Edit, |
| 1473 | content: None, | 1468 | content: None, |
| 1474 | edits: Some(vec![ | 1469 | edits: Some(vec![ |
| 1475 | EditOperation { | 1470 | Edit { |
| 1476 | old_text: "line 1".into(), | 1471 | old_text: "line 1".into(), |
| 1477 | new_text: "modified line 1".into(), | 1472 | new_text: "modified line 1".into(), |
| 1478 | }, | 1473 | }, |
| 1479 | EditOperation { | 1474 | Edit { |
| 1480 | old_text: "line 5".into(), | 1475 | old_text: "line 5".into(), |
| 1481 | new_text: "modified line 5".into(), | 1476 | new_text: "modified line 5".into(), |
| 1482 | }, | 1477 | }, |
| @@ -1533,7 +1528,7 @@ mod tests { · 50 unmodified lines |
| 1533 | path: "root/nonexistent_file.txt".into(), | 1528 | path: "root/nonexistent_file.txt".into(), |
| 1534 | mode: StreamingEditFileMode::Edit, | 1529 | mode: StreamingEditFileMode::Edit, |
| 1535 | content: None, | 1530 | content: None, |
| 1536 | edits: Some(vec![EditOperation { | 1531 | edits: Some(vec![Edit { |
| 1537 | old_text: "foo".into(), | 1532 | old_text: "foo".into(), |
| 1538 | new_text: "bar".into(), | 1533 | new_text: "bar".into(), |
| 1539 | }]), | 1534 | }]), |
| @@ -1587,7 +1582,7 @@ mod tests { · 47 unmodified lines |
| 1587 | path: "root/file.txt".into(), | 1582 | path: "root/file.txt".into(), |
| 1588 | mode: StreamingEditFileMode::Edit, | 1583 | mode: StreamingEditFileMode::Edit, |
| 1589 | content: None, | 1584 | content: None, |
| 1590 | edits: Some(vec![EditOperation { | 1585 | edits: Some(vec![Edit { |
| 1591 | old_text: "nonexistent text that is not in the file".into(), | 1586 | old_text: "nonexistent text that is not in the file".into(), |
| 1592 | new_text: "replacement".into(), | 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 | #[gpui::test] | 1612 | #[gpui::test] |
| 1691 | async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { | 1613 | async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { |
| 1692 | init_test(cx); | 1614 | init_test(cx); |
| @@ -1809,7 +1731,7 @@ mod tests { · 116 unmodified lines |
| 1809 | sender.send_partial(json!({ | 1731 | sender.send_partial(json!({ |
| 1810 | "display_description": "Overwrite file", | 1732 | "display_description": "Overwrite file", |
| 1811 | "path": "root/file.txt", | 1733 | "path": "root/file.txt", |
| 1812 | "mode": "overwrite" | 1734 | "mode": "write" |
| 1813 | })); | 1735 | })); |
| 1814 | cx.run_until_parked(); | 1736 | cx.run_until_parked(); |
| 1815 | | 1737 | |
| @@ -1817,7 +1739,7 @@ mod tests { · 1 unmodified line |
| 1817 | sender.send_final(json!({ | 1739 | sender.send_final(json!({ |
| 1818 | "display_description": "Overwrite file", | 1740 | "display_description": "Overwrite file", |
| 1819 | "path": "root/file.txt", | 1741 | "path": "root/file.txt", |
| 1820 | "mode": "overwrite", | 1742 | "mode": "write", |
| 1821 | "content": "new content" | 1743 | "content": "new content" |
| 1822 | })); | 1744 | })); |
| 1823 | | 1745 | |
| @@ -2026,14 +1948,14 @@ mod tests { · 202 unmodified lines |
| 2026 | sender.send_partial(json!({ | 1948 | sender.send_partial(json!({ |
| 2027 | "display_description": "Create new file", | 1949 | "display_description": "Create new file", |
| 2028 | "path": "root/dir/new_file.txt", | 1950 | "path": "root/dir/new_file.txt", |
| 2029 | "mode": "create" | 1951 | "mode": "write" |
| 2030 | })); | 1952 | })); |
| 2031 | cx.run_until_parked(); | 1953 | cx.run_until_parked(); |
| 2032 | | 1954 | |
| 2033 | sender.send_partial(json!({ | 1955 | sender.send_partial(json!({ |
| 2034 | "display_description": "Create new file", | 1956 | "display_description": "Create new file", |
| 2035 | "path": "root/dir/new_file.txt", | 1957 | "path": "root/dir/new_file.txt", |
| 2036 | "mode": "create", | 1958 | "mode": "write", |
| 2037 | "content": "Hello, " | 1959 | "content": "Hello, " |
| 2038 | })); | 1960 | })); |
| 2039 | cx.run_until_parked(); | 1961 | cx.run_until_parked(); |
| @@ -2042,7 +1964,7 @@ mod tests { · 2 unmodified lines |
| 2042 | sender.send_final(json!({ | 1964 | sender.send_final(json!({ |
| 2043 | "display_description": "Create new file", | 1965 | "display_description": "Create new file", |
| 2044 | "path": "root/dir/new_file.txt", | 1966 | "path": "root/dir/new_file.txt", |
| 2045 | "mode": "create", | 1967 | "mode": "write", |
| 2046 | "content": "Hello, World!" | 1968 | "content": "Hello, World!" |
| 2047 | })); | 1969 | })); |
| 2048 | | 1970 | |
| @@ -2304,14 +2226,16 @@ mod tests { · 255 unmodified lines |
| 2304 | })); | 2226 | })); |
| 2305 | cx.run_until_parked(); | 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 | let buffer_text = project.update(cx, |project, cx| { | 2232 | let buffer_text = project.update(cx, |project, cx| { |
| 2309 | let pp = project | 2233 | let pp = project |
| 2310 | .find_project_path(&PathBuf::from("root/file.txt"), cx) | 2234 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| 2311 | .unwrap(); | 2235 | .unwrap(); |
| 2312 | project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) | 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 | // Edit 3 appears — edit 2 is now complete and should be applied | 2240 | // Edit 3 appears — edit 2 is now complete and should be applied |
| 2317 | sender.send_partial(json!({ | 2241 | sender.send_partial(json!({ |
| @@ -2326,14 +2250,15 @@ mod tests { · 8 unmodified lines |
| 2326 | })); | 2250 | })); |
| 2327 | cx.run_until_parked(); | 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 | let buffer_text = project.update(cx, |project, cx| { | 2255 | let buffer_text = project.update(cx, |project, cx| { |
| 2331 | let pp = project | 2256 | let pp = project |
| 2332 | .find_project_path(&PathBuf::from("root/file.txt"), cx) | 2257 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| 2333 | .unwrap(); | 2258 | .unwrap(); |
| 2334 | project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) | 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 | // Send final | 2263 | // Send final |
| 2339 | sender.send_final(json!({ | 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 | #[gpui::test] | 2394 | #[gpui::test] |
| 2546 | async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { | 2395 | async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { |
| 2547 | init_test(cx); | 2396 | init_test(cx); |
| @@ -2590,7 +2439,10 @@ mod tests { · 42 unmodified lines |
| 2590 | })); | 2439 | })); |
| 2591 | cx.run_until_parked(); | 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 | let buffer_text = project.update(cx, |project, cx| { | 2446 | let buffer_text = project.update(cx, |project, cx| { |
| 2595 | let pp = project | 2447 | let pp = project |
| 2596 | .find_project_path(&PathBuf::from("root/file.txt"), cx) | 2448 | .find_project_path(&PathBuf::from("root/file.txt"), cx) |
| @@ -2599,8 +2451,8 @@ mod tests { · 2 unmodified lines |
| 2599 | }); | 2451 | }); |
| 2600 | assert_eq!( | 2452 | assert_eq!( |
| 2601 | buffer_text.as_deref(), | 2453 | buffer_text.as_deref(), |
| 2602 | Some("hello world\n"), | 2454 | Some("goodbye worldhello world\n"), |
| 2603 | "Single in-progress edit should not be applied during streaming" | 2455 | "In-progress streaming diff: new text inserted, old text not yet removed" |
| 2604 | ); | 2456 | ); |
| 2605 | | 2457 | |
| 2606 | // Send final — the edit is applied during finalization | 2458 | // Send final — the edit is applied during finalization |
| @@ -2795,12 +2647,12 @@ mod tests { · 188 unmodified lines |
| 2795 | sender.send_partial(json!({ | 2647 | sender.send_partial(json!({ |
| 2796 | "display_description": "Create", | 2648 | "display_description": "Create", |
| 2797 | "path": "root/dir/new.txt", | 2649 | "path": "root/dir/new.txt", |
| 2798 | "mode": "create" | 2650 | "mode": "write" |
| 2799 | })); | 2651 | })); |
| 2800 | sender.send_final(json!({ | 2652 | sender.send_final(json!({ |
| 2801 | "display_description": "Create", | 2653 | "display_description": "Create", |
| 2802 | "path": "root/dir/new.txt", | 2654 | "path": "root/dir/new.txt", |
| 2803 | "mode": "create", | 2655 | "mode": "write", |
| 2804 | "content": "streamed content" | 2656 | "content": "streamed content" |
| 2805 | })); | 2657 | })); |
| 2806 | | 2658 | |
| @@ -2813,7 +2665,7 @@ mod tests { · 6 unmodified lines |
| 2813 | | 2665 | |
| 2814 | #[gpui::test] | 2666 | #[gpui::test] |
| 2815 | async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { | 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 | let result = test_resolve_path(&mode, "root/new.txt", cx); | 2670 | let result = test_resolve_path(&mode, "root/new.txt", cx); |
| 2819 | assert_resolved_path_eq(result.await, rel_path("new.txt")); | 2671 | assert_resolved_path_eq(result.await, rel_path("new.txt")); |
| @@ -2825,9 +2677,12 @@ mod tests { · 5 unmodified lines |
| 2825 | assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); | 2677 | assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); |
| 2826 | | 2678 | |
| 2827 | let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); | 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 | assert_eq!( | 2683 | assert_eq!( |
| 2829 | result.await.unwrap_err().to_string(), | 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 | let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); | 2688 | let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); |
| @@ -3003,14 +2858,14 @@ mod tests { · 169 unmodified lines |
| 3003 | sender.send_partial(json!({ | 2858 | sender.send_partial(json!({ |
| 3004 | "display_description": "Create main function", | 2859 | "display_description": "Create main function", |
| 3005 | "path": "root/src/main.rs", | 2860 | "path": "root/src/main.rs", |
| 3006 | "mode": "overwrite" | 2861 | "mode": "write" |
| 3007 | })); | 2862 | })); |
| 3008 | cx.run_until_parked(); | 2863 | cx.run_until_parked(); |
| 3009 | | 2864 | |
| 3010 | sender.send_final(json!({ | 2865 | sender.send_final(json!({ |
| 3011 | "display_description": "Create main function", | 2866 | "display_description": "Create main function", |
| 3012 | "path": "root/src/main.rs", | 2867 | "path": "root/src/main.rs", |
| 3013 | "mode": "overwrite", | 2868 | "mode": "write", |
| 3014 | "content": UNFORMATTED_CONTENT | 2869 | "content": UNFORMATTED_CONTENT |
| 3015 | })); | 2870 | })); |
| 3016 | | 2871 | |
| @@ -3060,14 +2915,14 @@ mod tests { · 43 unmodified lines |
| 3060 | sender.send_partial(json!({ | 2915 | sender.send_partial(json!({ |
| 3061 | "display_description": "Update main function", | 2916 | "display_description": "Update main function", |
| 3062 | "path": "root/src/main.rs", | 2917 | "path": "root/src/main.rs", |
| 3063 | "mode": "overwrite" | 2918 | "mode": "write" |
| 3064 | })); | 2919 | })); |
| 3065 | cx.run_until_parked(); | 2920 | cx.run_until_parked(); |
| 3066 | | 2921 | |
| 3067 | sender.send_final(json!({ | 2922 | sender.send_final(json!({ |
| 3068 | "display_description": "Update main function", | 2923 | "display_description": "Update main function", |
| 3069 | "path": "root/src/main.rs", | 2924 | "path": "root/src/main.rs", |
| 3070 | "mode": "overwrite", | 2925 | "mode": "write", |
| 3071 | "content": UNFORMATTED_CONTENT | 2926 | "content": UNFORMATTED_CONTENT |
| 3072 | })); | 2927 | })); |
| 3073 | | 2928 | |
| @@ -3136,7 +2991,7 @@ mod tests { · 62 unmodified lines |
| 3136 | let input = StreamingEditFileToolInput { | 2991 | let input = StreamingEditFileToolInput { |
| 3137 | display_description: "Create main function".into(), | 2992 | display_description: "Create main function".into(), |
| 3138 | path: "root/src/main.rs".into(), | 2993 | path: "root/src/main.rs".into(), |
| 3139 | mode: StreamingEditFileMode::Overwrite, | 2994 | mode: StreamingEditFileMode::Write, |
| 3140 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), | 2995 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), |
| 3141 | edits: None, | 2996 | edits: None, |
| 3142 | }; | 2997 | }; |
| @@ -3183,7 +3038,7 @@ mod tests { · 40 unmodified lines |
| 3183 | let input = StreamingEditFileToolInput { | 3038 | let input = StreamingEditFileToolInput { |
| 3184 | display_description: "Update main function".into(), | 3039 | display_description: "Update main function".into(), |
| 3185 | path: "root/src/main.rs".into(), | 3040 | path: "root/src/main.rs".into(), |
| 3186 | mode: StreamingEditFileMode::Overwrite, | 3041 | mode: StreamingEditFileMode::Write, |
| 3187 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), | 3042 | content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), |
| 3188 | edits: None, | 3043 | edits: None, |
| 3189 | }; | 3044 | }; |
| @@ -3904,11 +3759,7 @@ mod tests { · 714 unmodified lines |
| 3904 | language_registry, | 3759 | language_registry, |
| 3905 | )); | 3760 | )); |
| 3906 | | 3761 | |
| 3907 | let modes = vec![ | 3762 | let modes = vec![StreamingEditFileMode::Edit, StreamingEditFileMode::Write]; |
| 3908 | StreamingEditFileMode::Edit, | | |
| 3909 | StreamingEditFileMode::Create, | | |
| 3910 | StreamingEditFileMode::Overwrite, | | |
| 3911 | ]; | | |
| 3912 | | 3763 | |
| 3913 | for _mode in modes { | 3764 | for _mode in modes { |
| 3914 | // Test .zed path with different modes | 3765 | // Test .zed path with different modes |
| @@ -4061,7 +3912,7 @@ mod tests { · 146 unmodified lines |
| 4061 | ToolInput::resolved(StreamingEditFileToolInput { | 3912 | ToolInput::resolved(StreamingEditFileToolInput { |
| 4062 | display_description: "Edit file".into(), | 3913 | display_description: "Edit file".into(), |
| 4063 | path: path!("/main.rs").into(), | 3914 | path: path!("/main.rs").into(), |
| 4064 | mode: StreamingEditFileMode::Overwrite, | 3915 | mode: StreamingEditFileMode::Write, |
| 4065 | content: Some("new content".into()), | 3916 | content: Some("new content".into()), |
| 4066 | edits: None, | 3917 | edits: None, |
| 4067 | }), | 3918 | }), |
| @@ -4090,7 +3941,7 @@ mod tests { · 22 unmodified lines |
| 4090 | ToolInput::resolved(StreamingEditFileToolInput { | 3941 | ToolInput::resolved(StreamingEditFileToolInput { |
| 4091 | display_description: "Edit file".into(), | 3942 | display_description: "Edit file".into(), |
| 4092 | path: path!("/main.rs").into(), | 3943 | path: path!("/main.rs").into(), |
| 4093 | mode: StreamingEditFileMode::Overwrite, | 3944 | mode: StreamingEditFileMode::Write, |
| 4094 | content: Some("dropped content".into()), | 3945 | content: Some("dropped content".into()), |
| 4095 | edits: None, | 3946 | edits: None, |
| 4096 | }), | 3947 | }), |
| @@ -4171,7 +4022,7 @@ mod tests { · 74 unmodified lines |
| 4171 | path: "root/test.txt".into(), | 4022 | path: "root/test.txt".into(), |
| 4172 | mode: StreamingEditFileMode::Edit, | 4023 | mode: StreamingEditFileMode::Edit, |
| 4173 | content: None, | 4024 | content: None, |
| 4174 | edits: Some(vec![EditOperation { | 4025 | edits: Some(vec![Edit { |
| 4175 | old_text: "original content".into(), | 4026 | old_text: "original content".into(), |
| 4176 | new_text: "modified content".into(), | 4027 | new_text: "modified content".into(), |
| 4177 | }]), | 4028 | }]), |
| @@ -4196,7 +4047,7 @@ mod tests { · 18 unmodified lines |
| 4196 | path: "root/test.txt".into(), | 4047 | path: "root/test.txt".into(), |
| 4197 | mode: StreamingEditFileMode::Edit, | 4048 | mode: StreamingEditFileMode::Edit, |
| 4198 | content: None, | 4049 | content: None, |
| 4199 | edits: Some(vec![EditOperation { | 4050 | edits: Some(vec![Edit { |
| 4200 | old_text: "modified content".into(), | 4051 | old_text: "modified content".into(), |
| 4201 | new_text: "further modified content".into(), | 4052 | new_text: "further modified content".into(), |
| 4202 | }]), | 4053 | }]), |
| @@ -4305,7 +4156,7 @@ mod tests { · 102 unmodified lines |
| 4305 | path: "root/test.txt".into(), | 4156 | path: "root/test.txt".into(), |
| 4306 | mode: StreamingEditFileMode::Edit, | 4157 | mode: StreamingEditFileMode::Edit, |
| 4307 | content: None, | 4158 | content: None, |
| 4308 | edits: Some(vec![EditOperation { | 4159 | edits: Some(vec![Edit { |
| 4309 | old_text: "externally modified content".into(), | 4160 | old_text: "externally modified content".into(), |
| 4310 | new_text: "new content".into(), | 4161 | new_text: "new content".into(), |
| 4311 | }]), | 4162 | }]), |
| @@ -4409,7 +4260,7 @@ mod tests { · 97 unmodified lines |
| 4409 | path: "root/test.txt".into(), | 4260 | path: "root/test.txt".into(), |
| 4410 | mode: StreamingEditFileMode::Edit, | 4261 | mode: StreamingEditFileMode::Edit, |
| 4411 | content: None, | 4262 | content: None, |
| 4412 | edits: Some(vec![EditOperation { | 4263 | edits: Some(vec![Edit { |
| 4413 | old_text: "original content".into(), | 4264 | old_text: "original content".into(), |
| 4414 | new_text: "new content".into(), | 4265 | new_text: "new content".into(), |
| 4415 | }]), | 4266 | }]), |
| @@ -4441,15 +4292,14 @@ mod tests { · 25 unmodified lines |
| 4441 | } | 4292 | } |
| 4442 | | 4293 | |
| 4443 | #[gpui::test] | 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 | init_test(cx); | 4296 | init_test(cx); |
| 4446 | | 4297 | |
| 4447 | let fs = project::FakeFs::new(cx.executor()); | 4298 | let fs = project::FakeFs::new(cx.executor()); |
| 4448 | // The file content is crafted so that edit 1's replacement still | 4299 | // Edit 1's replacement introduces text that contains edit 2's |
| 4449 | // contains the old_text of edit 2 as a contiguous substring. | 4300 | // old_text as a substring. Because edits resolve sequentially |
| 4450 | // Without early overlap detection, edit 2 would silently match | 4301 | // against the current buffer, edit 2 finds a unique match in |
| 4451 | // inside the already-modified region and corrupt the file instead | 4302 | // the modified buffer and succeeds. |
| 4452 | // of producing a clear "Conflicting edit ranges" error. | | |
| 4453 | fs.insert_tree( | 4303 | fs.insert_tree( |
| 4454 | "/root", | 4304 | "/root", |
| 4455 | json!({ | 4305 | json!({ |
| @@ -4492,17 +4342,10 @@ mod tests { · 36 unmodified lines |
| 4492 | })); | 4342 | })); |
| 4493 | cx.run_until_parked(); | 4343 | cx.run_until_parked(); |
| 4494 | | 4344 | |
| 4495 | // Edit 1 targets "bbb\nccc" (lines 2-3) and replaces it with | 4345 | // Edit 1 replaces "bbb\nccc" with "XXX\nccc\nddd", so the |
| 4496 | // text that preserves "ccc\nddd" as a contiguous substring in the | 4346 | // buffer becomes "aaa\nXXX\nccc\nddd\nddd\neee\n". |
| 4497 | // buffer — so edit 2's old_text will still match after edit 1 is | 4347 | // Edit 2's old_text "ccc\nddd" matches the first occurrence |
| 4498 | // applied. | 4348 | // in the modified buffer and replaces it with "ZZZ". |
| 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 | // | | |
| 4506 | // Edit 3 exists only to mark edit 2 as "complete" during streaming. | 4349 | // Edit 3 exists only to mark edit 2 as "complete" during streaming. |
| 4507 | sender.send_partial(json!({ | 4350 | sender.send_partial(json!({ |
| 4508 | "display_description": "Overlapping edits", | 4351 | "display_description": "Overlapping edits", |
| @@ -4529,23 +4372,10 @@ mod tests { · 20 unmodified lines |
| 4529 | })); | 4372 | })); |
| 4530 | | 4373 | |
| 4531 | let result = task.await; | 4374 | let result = task.await; |
| 4532 | // We expect a "Conflicting edit ranges" error. Currently the overlap | 4375 | let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { |
| 4533 | // goes undetected during streaming and the file gets silently | 4376 | panic!("expected success"); |
| 4534 | // corrupted, so this assertion will fail until we add early overlap | 4377 | }; |
| 4535 | // detection. | 4378 | assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n"); |
| 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 | } | | |
| 4549 | } | 4379 | } |
| 4550 | | 4380 | |
| 4551 | #[gpui::test] | 4381 | #[gpui::test] |
| @@ -4585,7 +4415,7 @@ mod tests { · 33 unmodified lines |
| 4585 | sender.send_partial(json!({ | 4415 | sender.send_partial(json!({ |
| 4586 | "display_description": "Create new file", | 4416 | "display_description": "Create new file", |
| 4587 | "path": "root/dir/new_file.txt", | 4417 | "path": "root/dir/new_file.txt", |
| 4588 | "mode": "create" | 4418 | "mode": "write" |
| 4589 | })); | 4419 | })); |
| 4590 | cx.run_until_parked(); | 4420 | cx.run_until_parked(); |
| 4591 | | 4421 | |
| @@ -4593,7 +4423,7 @@ mod tests { · 1 unmodified line |
| 4593 | sender.send_partial(json!({ | 4423 | sender.send_partial(json!({ |
| 4594 | "display_description": "Create new file", | 4424 | "display_description": "Create new file", |
| 4595 | "path": "root/dir/new_file.txt", | 4425 | "path": "root/dir/new_file.txt", |
| 4596 | "mode": "create", | 4426 | "mode": "write", |
| 4597 | "content": "line 1\n" | 4427 | "content": "line 1\n" |
| 4598 | })); | 4428 | })); |
| 4599 | cx.run_until_parked(); | 4429 | cx.run_until_parked(); |
| @@ -4611,7 +4441,7 @@ mod tests { · 11 unmodified lines |
| 4611 | sender.send_partial(json!({ | 4441 | sender.send_partial(json!({ |
| 4612 | "display_description": "Create new file", | 4442 | "display_description": "Create new file", |
| 4613 | "path": "root/dir/new_file.txt", | 4443 | "path": "root/dir/new_file.txt", |
| 4614 | "mode": "create", | 4444 | "mode": "write", |
| 4615 | "content": "line 1\nline 2\n" | 4445 | "content": "line 1\nline 2\n" |
| 4616 | })); | 4446 | })); |
| 4617 | cx.run_until_parked(); | 4447 | cx.run_until_parked(); |
| @@ -4621,7 +4451,7 @@ mod tests { · 3 unmodified lines |
| 4621 | sender.send_partial(json!({ | 4451 | sender.send_partial(json!({ |
| 4622 | "display_description": "Create new file", | 4452 | "display_description": "Create new file", |
| 4623 | "path": "root/dir/new_file.txt", | 4453 | "path": "root/dir/new_file.txt", |
| 4624 | "mode": "create", | 4454 | "mode": "write", |
| 4625 | "content": "line 1\nline 2\nline 3\n" | 4455 | "content": "line 1\nline 2\nline 3\n" |
| 4626 | })); | 4456 | })); |
| 4627 | cx.run_until_parked(); | 4457 | cx.run_until_parked(); |
| @@ -4634,7 +4464,7 @@ mod tests { · 6 unmodified lines |
| 4634 | sender.send_final(json!({ | 4464 | sender.send_final(json!({ |
| 4635 | "display_description": "Create new file", | 4465 | "display_description": "Create new file", |
| 4636 | "path": "root/dir/new_file.txt", | 4466 | "path": "root/dir/new_file.txt", |
| 4637 | "mode": "create", | 4467 | "mode": "write", |
| 4638 | "content": "line 1\nline 2\nline 3\n" | 4468 | "content": "line 1\nline 2\nline 3\n" |
| 4639 | })); | 4469 | })); |
| 4640 | | 4470 | |
| @@ -4688,7 +4518,7 @@ mod tests { · 47 unmodified lines |
| 4688 | sender.send_partial(json!({ | 4518 | sender.send_partial(json!({ |
| 4689 | "display_description": "Overwrite file", | 4519 | "display_description": "Overwrite file", |
| 4690 | "path": "root/file.txt", | 4520 | "path": "root/file.txt", |
| 4691 | "mode": "overwrite" | 4521 | "mode": "write" |
| 4692 | })); | 4522 | })); |
| 4693 | cx.run_until_parked(); | 4523 | cx.run_until_parked(); |
| 4694 | | 4524 | |
| @@ -4706,7 +4536,7 @@ mod tests { · 11 unmodified lines |
| 4706 | sender.send_partial(json!({ | 4536 | sender.send_partial(json!({ |
| 4707 | "display_description": "Overwrite file", | 4537 | "display_description": "Overwrite file", |
| 4708 | "path": "root/file.txt", | 4538 | "path": "root/file.txt", |
| 4709 | "mode": "overwrite", | 4539 | "mode": "write", |
| 4710 | "content": "new line 1\n" | 4540 | "content": "new line 1\n" |
| 4711 | })); | 4541 | })); |
| 4712 | cx.run_until_parked(); | 4542 | cx.run_until_parked(); |
| @@ -4720,7 +4550,7 @@ mod tests { · 7 unmodified lines |
| 4720 | sender.send_final(json!({ | 4550 | sender.send_final(json!({ |
| 4721 | "display_description": "Overwrite file", | 4551 | "display_description": "Overwrite file", |
| 4722 | "path": "root/file.txt", | 4552 | "path": "root/file.txt", |
| 4723 | "mode": "overwrite", | 4553 | "mode": "write", |
| 4724 | "content": "new line 1\nnew line 2\n" | 4554 | "content": "new line 1\nnew line 2\n" |
| 4725 | })); | 4555 | })); |
| 4726 | | 4556 | |
| @@ -4781,7 +4611,7 @@ mod tests { · 54 unmodified lines |
| 4781 | sender.send_partial(json!({ | 4611 | sender.send_partial(json!({ |
| 4782 | "display_description": "Overwrite file", | 4612 | "display_description": "Overwrite file", |
| 4783 | "path": "root/file.txt", | 4613 | "path": "root/file.txt", |
| 4784 | "mode": "overwrite" | 4614 | "mode": "write" |
| 4785 | })); | 4615 | })); |
| 4786 | cx.run_until_parked(); | 4616 | cx.run_until_parked(); |
| 4787 | | 4617 | |
| @@ -4799,7 +4629,7 @@ mod tests { · 11 unmodified lines |
| 4799 | sender.send_partial(json!({ | 4629 | sender.send_partial(json!({ |
| 4800 | "display_description": "Overwrite file", | 4630 | "display_description": "Overwrite file", |
| 4801 | "path": "root/file.txt", | 4631 | "path": "root/file.txt", |
| 4802 | "mode": "overwrite", | 4632 | "mode": "write", |
| 4803 | "content": "new line 1\n" | 4633 | "content": "new line 1\n" |
| 4804 | })); | 4634 | })); |
| 4805 | cx.run_until_parked(); | 4635 | cx.run_until_parked(); |
| @@ -4809,7 +4639,7 @@ mod tests { · 3 unmodified lines |
| 4809 | sender.send_partial(json!({ | 4639 | sender.send_partial(json!({ |
| 4810 | "display_description": "Overwrite file", | 4640 | "display_description": "Overwrite file", |
| 4811 | "path": "root/file.txt", | 4641 | "path": "root/file.txt", |
| 4812 | "mode": "overwrite", | 4642 | "mode": "write", |
| 4813 | "content": "new line 1\nnew line 2\n" | 4643 | "content": "new line 1\nnew line 2\n" |
| 4814 | })); | 4644 | })); |
| 4815 | cx.run_until_parked(); | 4645 | cx.run_until_parked(); |
| @@ -4822,7 +4652,7 @@ mod tests { · 6 unmodified lines |
| 4822 | sender.send_final(json!({ | 4652 | sender.send_final(json!({ |
| 4823 | "display_description": "Overwrite file", | 4653 | "display_description": "Overwrite file", |
| 4824 | "path": "root/file.txt", | 4654 | "path": "root/file.txt", |
| 4825 | "mode": "overwrite", | 4655 | "mode": "write", |
| 4826 | "content": "new line 1\nnew line 2\nnew line 3\n" | 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 | assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); | 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 | fn init_test(cx: &mut TestAppContext) { | 4753 | fn init_test(cx: &mut TestAppContext) { |
| 4841 | cx.update(|cx| { | 4754 | cx.update(|cx| { |
| 4842 | let settings_store = SettingsStore::test(cx); | 4755 | let settings_store = SettingsStore::test(cx); |