diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a16a4a7895b28b281d5a1d8d883206252b33c412..bff601b3270d1c35c43c6dae77b0fc155ad8f963 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -322,6 +322,10 @@ impl ToolCall { self.status = status.into(); } + if let Some(tool_name) = tool_name_from_meta(&meta) { + self.tool_name = Some(tool_name); + } + if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) { self.subagent_session_info = Some(subagent_session_info); } @@ -1009,6 +1013,33 @@ struct RunningTurn { send_task: Task<()>, } +#[derive(Clone)] +struct InferredEditCandidate { + buffer: Entity, + baseline_snapshot: text::BufferSnapshot, + existed_on_disk: bool, + was_dirty: bool, +} + +#[derive(Clone)] +enum InferredEditCandidateState { + Pending { + nonce: u64, + }, + Ready { + nonce: u64, + candidate: InferredEditCandidate, + }, +} + +impl InferredEditCandidateState { + fn nonce(&self) -> u64 { + match self { + Self::Pending { nonce } | Self::Ready { nonce, .. } => *nonce, + } + } +} + pub struct AcpThread { session_id: acp::SessionId, work_dirs: Option, @@ -1029,6 +1060,10 @@ pub struct AcpThread { terminals: HashMap>, pending_terminal_output: HashMap>>, pending_terminal_exit: HashMap, + inferred_edit_candidates: + HashMap>, + finalizing_inferred_edit_tool_calls: HashSet, + next_inferred_edit_candidate_nonce: u64, had_error: bool, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. draft_prompt: Option>, @@ -1216,6 +1251,9 @@ impl AcpThread { terminals: HashMap::default(), pending_terminal_output: HashMap::default(), pending_terminal_exit: HashMap::default(), + inferred_edit_candidates: HashMap::default(), + finalizing_inferred_edit_tool_calls: HashSet::default(), + next_inferred_edit_candidate_nonce: 0, had_error: false, draft_prompt: None, ui_scroll_position: None, @@ -1320,7 +1358,7 @@ impl AcpThread { status: ToolCallStatus::InProgress | ToolCallStatus::Pending, .. }, - ) if call.diffs().next().is_some() => { + ) if call.diffs().next().is_some() || Self::should_infer_external_edits(call) => { return true; } AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} @@ -1711,21 +1749,456 @@ impl AcpThread { cx.emit(AcpThreadEvent::Retry(status)); } + fn should_infer_external_edits(tool_call: &ToolCall) -> bool { + tool_call.tool_name.is_none() + && tool_call.kind == acp::ToolKind::Edit + && !tool_call.locations.is_empty() + } + + fn is_inferred_edit_terminal_status(status: &ToolCallStatus) -> bool { + matches!(status, ToolCallStatus::Completed | ToolCallStatus::Failed) + } + + fn allocate_inferred_edit_candidate_nonce(&mut self) -> u64 { + let nonce = self.next_inferred_edit_candidate_nonce; + self.next_inferred_edit_candidate_nonce = + self.next_inferred_edit_candidate_nonce.wrapping_add(1); + nonce + } + + fn remove_inferred_edit_candidate_if_matching( + &mut self, + tool_call_id: &acp::ToolCallId, + abs_path: &PathBuf, + nonce: u64, + ) { + let remove_tool_call = + if let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) { + let should_remove = candidates + .get(abs_path) + .is_some_and(|candidate_state| candidate_state.nonce() == nonce); + if should_remove { + candidates.remove(abs_path); + } + candidates.is_empty() + } else { + false + }; + + if remove_tool_call { + self.inferred_edit_candidates.remove(tool_call_id); + self.finalizing_inferred_edit_tool_calls + .remove(tool_call_id); + } + } + + fn clear_inferred_edit_candidates_for_tool_calls( + &mut self, + tool_call_ids: impl IntoIterator, + ) { + for tool_call_id in tool_call_ids { + self.inferred_edit_candidates.remove(&tool_call_id); + self.finalizing_inferred_edit_tool_calls + .remove(&tool_call_id); + } + } + + fn finalize_all_inferred_edit_tool_calls(&mut self, cx: &mut Context) { + let tool_call_ids = self + .inferred_edit_candidates + .keys() + .filter(|tool_call_id| { + self.tool_call(tool_call_id).is_some_and(|(_, tool_call)| { + Self::should_infer_external_edits(tool_call) + && Self::is_inferred_edit_terminal_status(&tool_call.status) + }) + }) + .cloned() + .collect::>(); + for tool_call_id in tool_call_ids { + self.finalize_inferred_edit_tool_call(tool_call_id, cx); + } + } + + fn sync_inferred_edit_candidate_paths( + &mut self, + tool_call_id: &acp::ToolCallId, + locations: &[acp::ToolCallLocation], + ) { + let mut current_paths = HashSet::default(); + for location in locations { + current_paths.insert(location.path.clone()); + } + + let remove_tool_call = + if let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) { + candidates.retain(|path, _| current_paths.contains(path)); + candidates.is_empty() + } else { + false + }; + + if remove_tool_call { + self.inferred_edit_candidates.remove(tool_call_id); + self.finalizing_inferred_edit_tool_calls + .remove(tool_call_id); + } + } + + fn register_inferred_edit_locations( + &mut self, + tool_call_id: acp::ToolCallId, + locations: &[acp::ToolCallLocation], + cx: &mut Context, + ) { + self.sync_inferred_edit_candidate_paths(&tool_call_id, locations); + + let mut unique_paths = HashSet::default(); + for location in locations { + let abs_path = location.path.clone(); + if !unique_paths.insert(abs_path.clone()) { + continue; + } + + let nonce = self.allocate_inferred_edit_candidate_nonce(); + let candidates = self + .inferred_edit_candidates + .entry(tool_call_id.clone()) + .or_default(); + if candidates.contains_key(&abs_path) { + continue; + } + + candidates.insert( + abs_path.clone(), + InferredEditCandidateState::Pending { nonce }, + ); + + let project = self.project.clone(); + let tool_call_id = tool_call_id.clone(); + cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + let open_buffer = project.update(cx, |project, cx| { + let project_path = project.project_path_for_absolute_path(&abs_path, cx)?; + Some(project.open_buffer(project_path, cx)) + }); + + let Some(open_buffer) = open_buffer else { + this.update(cx, |this, _| { + this.remove_inferred_edit_candidate_if_matching( + &tool_call_id, + &abs_path, + nonce, + ); + }) + .ok(); + return Ok(()); + }; + + let buffer = match open_buffer.await { + Ok(buffer) => buffer, + Err(_) => { + this.update(cx, |this, _| { + this.remove_inferred_edit_candidate_if_matching( + &tool_call_id, + &abs_path, + nonce, + ); + }) + .ok(); + return Ok(()); + } + }; + + let (baseline_snapshot, existed_on_disk, was_dirty) = + buffer.read_with(cx, |buffer, _| { + ( + buffer.text_snapshot(), + buffer.file().is_some_and(|file| file.disk_state().exists()), + buffer.is_dirty(), + ) + }); + + this.update(cx, |this, _| { + let Some(candidates) = this.inferred_edit_candidates.get_mut(&tool_call_id) + else { + return; + }; + let Some(candidate_state) = candidates.get_mut(&abs_path) else { + return; + }; + if candidate_state.nonce() != nonce { + return; + } + *candidate_state = InferredEditCandidateState::Ready { + nonce, + candidate: InferredEditCandidate { + buffer, + baseline_snapshot, + existed_on_disk, + was_dirty, + }, + }; + }) + .ok(); + + Ok(()) + }) + .detach_and_log_err(cx); + } + } + + fn finalize_inferred_edit_tool_call( + &mut self, + tool_call_id: acp::ToolCallId, + cx: &mut Context, + ) { + let should_finalize = self.tool_call(&tool_call_id).is_some_and(|(_, tool_call)| { + Self::should_infer_external_edits(tool_call) + && Self::is_inferred_edit_terminal_status(&tool_call.status) + }); + if !should_finalize { + self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id]); + return; + } + + if !self + .finalizing_inferred_edit_tool_calls + .insert(tool_call_id.clone()) + { + return; + } + + let project = self.project.clone(); + let action_log = self.action_log.clone(); + cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + const MAX_ATTEMPTS: usize = 3; + const ATTEMPT_DELAY: Duration = Duration::from_millis(50); + + for attempt in 0..MAX_ATTEMPTS { + let (ready_candidates, has_pending) = this + .read_with(cx, |this, _| { + let Some(candidates) = this.inferred_edit_candidates.get(&tool_call_id) + else { + return (Vec::new(), false); + }; + + let mut ready_candidates = Vec::new(); + let mut has_pending = false; + for (abs_path, candidate_state) in candidates { + match candidate_state { + InferredEditCandidateState::Pending { .. } => has_pending = true, + InferredEditCandidateState::Ready { nonce, candidate } => { + ready_candidates.push(( + abs_path.clone(), + *nonce, + candidate.clone(), + )); + } + } + } + + (ready_candidates, has_pending) + }) + .unwrap_or((Vec::new(), false)); + + if ready_candidates.is_empty() && !has_pending { + break; + } + + for (_, _, candidate) in &ready_candidates { + let should_reload = candidate + .buffer + .read_with(cx, |buffer, _| !buffer.is_dirty()); + if !should_reload { + continue; + } + + let reload = project.update(cx, |project, cx| { + let mut buffers = HashSet::default(); + buffers.insert(candidate.buffer.clone()); + project.reload_buffers(buffers, false, cx) + }); + reload.await.log_err(); + } + + if !has_pending || attempt + 1 == MAX_ATTEMPTS { + break; + } + + cx.background_executor().timer(ATTEMPT_DELAY).await; + } + + let ready_candidates = this + .read_with(cx, |this, _| { + this.inferred_edit_candidates + .get(&tool_call_id) + .into_iter() + .flat_map(|candidates| candidates.iter()) + .filter_map(|(abs_path, candidate_state)| match candidate_state { + InferredEditCandidateState::Pending { .. } => None, + InferredEditCandidateState::Ready { nonce, candidate } => { + Some((abs_path.clone(), *nonce, candidate.clone())) + } + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut processed_candidates = Vec::new(); + for (abs_path, nonce, candidate) in ready_candidates { + let still_current = this + .read_with(cx, |this, _| { + this.inferred_edit_candidates + .get(&tool_call_id) + .and_then(|candidates| candidates.get(&abs_path)) + .is_some_and(|candidate_state| candidate_state.nonce() == nonce) + }) + .unwrap_or(false); + if !still_current { + continue; + } + + if candidate.was_dirty { + processed_candidates.push((abs_path, nonce)); + continue; + } + + let already_changed = action_log.read_with(cx, |action_log, cx| { + action_log.has_changed_buffer(&candidate.buffer, cx) + }); + if already_changed { + processed_candidates.push((abs_path, nonce)); + continue; + } + + let (current_snapshot, current_exists, current_dirty) = + candidate.buffer.read_with(cx, |buffer, _| { + ( + buffer.text_snapshot(), + buffer.file().is_some_and(|file| file.disk_state().exists()), + buffer.is_dirty(), + ) + }); + + if current_dirty { + processed_candidates.push((abs_path, nonce)); + continue; + } + + let buffer_changed = current_snapshot.text() != candidate.baseline_snapshot.text(); + + if !candidate.existed_on_disk { + if current_exists || buffer_changed { + action_log.update(cx, |action_log, cx| { + action_log.infer_buffer_created( + candidate.buffer.clone(), + candidate.baseline_snapshot.clone(), + cx, + ); + }); + } + processed_candidates.push((abs_path, nonce)); + } else if !current_exists { + action_log.update(cx, |action_log, cx| { + action_log.infer_buffer_deleted_from_snapshot( + candidate.buffer.clone(), + candidate.baseline_snapshot.clone(), + cx, + ); + }); + processed_candidates.push((abs_path, nonce)); + } else if buffer_changed { + action_log.update(cx, |action_log, cx| { + action_log.infer_buffer_edited_from_snapshot( + candidate.buffer.clone(), + candidate.baseline_snapshot.clone(), + cx, + ); + }); + processed_candidates.push((abs_path, nonce)); + } else { + processed_candidates.push((abs_path, nonce)); + } + } + + this.update(cx, |this, cx| { + for (abs_path, nonce) in processed_candidates { + this.remove_inferred_edit_candidate_if_matching( + &tool_call_id, + &abs_path, + nonce, + ); + } + + this.finalizing_inferred_edit_tool_calls + .remove(&tool_call_id); + + let should_retry = this + .inferred_edit_candidates + .get(&tool_call_id) + .is_some_and(|candidates| !candidates.is_empty()); + + if should_retry { + let tool_call_id = tool_call_id.clone(); + cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + cx.background_executor().timer(ATTEMPT_DELAY).await; + this.update(cx, |this, cx| { + this.finalize_inferred_edit_tool_call(tool_call_id, cx); + }) + .ok(); + Ok(()) + }) + .detach_and_log_err(cx); + } + }) + .ok(); + + Ok(()) + }) + .detach_and_log_err(cx); + } + + fn refresh_inferred_edit_tool_call( + &mut self, + tool_call_id: acp::ToolCallId, + cx: &mut Context, + ) { + let Some((_, tool_call)) = self.tool_call(&tool_call_id) else { + return; + }; + + let should_track = Self::should_infer_external_edits(tool_call); + let should_finalize = Self::is_inferred_edit_terminal_status(&tool_call.status); + let locations = tool_call.locations.clone(); + + if !should_track { + self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id]); + return; + } + + self.register_inferred_edit_locations(tool_call_id.clone(), &locations, cx); + + if should_finalize { + self.finalize_inferred_edit_tool_call(tool_call_id, cx); + } + } + pub fn update_tool_call( &mut self, update: impl Into, cx: &mut Context, ) -> Result<()> { let update = update.into(); + let tool_call_id = update.id().clone(); let languages = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); - let ix = match self.index_for_tool_call(update.id()) { + let ix = match self.index_for_tool_call(&tool_call_id) { Some(ix) => ix, None => { // Tool call not found - create a failed tool call entry let failed_tool_call = ToolCall { - id: update.id().clone(), + id: tool_call_id, label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( @@ -1778,6 +2251,7 @@ impl AcpThread { } cx.emit(AcpThreadEvent::EntryUpdated(ix)); + self.refresh_inferred_edit_tool_call(tool_call_id, cx); Ok(()) } @@ -1849,7 +2323,8 @@ impl AcpThread { self.push_entry(AgentThreadEntry::ToolCall(call), cx); }; - self.resolve_locations(id, cx); + self.resolve_locations(id.clone(), cx); + self.refresh_inferred_edit_tool_call(id, cx); Ok(()) } @@ -2211,6 +2686,7 @@ impl AcpThread { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); if r.stop_reason == acp::StopReason::MaxTokens { + this.finalize_all_inferred_edit_tool_calls(cx); this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Max tokens reached. Usage: {:?}", this.token_usage); @@ -2220,6 +2696,7 @@ impl AcpThread { let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); if canceled { this.mark_pending_tools_as_canceled(); + this.finalize_all_inferred_edit_tool_calls(cx); } // Handle refusal - distinguish between user prompt and tool call refusals @@ -2247,6 +2724,20 @@ impl AcpThread { // User prompt was refused - truncate back to before the user message let range = user_msg_ix..this.entries.len(); if range.start < range.end { + let removed_tool_call_ids = this.entries[user_msg_ix..] + .iter() + .filter_map(|entry| { + if let AgentThreadEntry::ToolCall(tool_call) = entry + { + Some(tool_call.id.clone()) + } else { + None + } + }) + .collect::>(); + this.clear_inferred_edit_candidates_for_tool_calls( + removed_tool_call_ids, + ); this.entries.truncate(user_msg_ix); cx.emit(AcpThreadEvent::EntriesRemoved(range)); } @@ -2258,12 +2749,14 @@ impl AcpThread { } } + this.finalize_all_inferred_edit_tool_calls(cx); cx.emit(AcpThreadEvent::Stopped(r.stop_reason)); Ok(Some(r)) } Err(e) => { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); + this.finalize_all_inferred_edit_tool_calls(cx); this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Error in run turn: {:?}", e); @@ -2283,6 +2776,7 @@ impl AcpThread { Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.mark_pending_tools_as_canceled(); + self.finalize_all_inferred_edit_tool_calls(cx); // Wait for the send task to complete cx.background_spawn(turn.send_task) @@ -2358,8 +2852,19 @@ impl AcpThread { .flat_map(|entry| entry.terminals()) .filter_map(|terminal| terminal.read(cx).id().clone().into()) .collect(); + let removed_tool_call_ids = this.entries[ix..] + .iter() + .filter_map(|entry| { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + Some(tool_call.id.clone()) + } else { + None + } + }) + .collect::>(); let range = ix..this.entries.len(); + this.clear_inferred_edit_candidates_for_tool_calls(removed_tool_call_ids); this.entries.truncate(ix); cx.emit(AcpThreadEvent::EntriesRemoved(range)); @@ -3769,6 +4274,226 @@ mod tests { assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls())); } + #[gpui::test] + async fn test_pending_edits_for_edit_tool_calls_with_locations(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "test", + acp::ToolCallUpdateFields::new() + .locations(vec![acp::ToolCallLocation::new(path!("/test/file.txt"))]), + )), + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + assert!(cx.read(|cx| thread.read(cx).has_pending_edit_tool_calls())); + } + + #[gpui::test] + async fn test_infer_external_modified_file_edits_from_tool_call_locations( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection.new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) + }) + .await + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "test", + acp::ToolCallUpdateFields::new() + .locations(vec![acp::ToolCallLocation::new(path!("/test/file.txt"))]), + )), + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + fs.save( + path!("/test/file.txt").as_ref(), + &"one\ntwo\nthree\n".into(), + Default::default(), + ) + .await + .unwrap(); + cx.run_until_parked(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "test", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), + cx, + ) + }) + .unwrap(); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + assert_eq!( + action_log.read_with(cx, |action_log, cx| action_log.changed_buffers(cx).len()), + 1 + ); + + action_log + .update(cx, |action_log, cx| action_log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/test/file.txt")).unwrap()).unwrap(), + "one\ntwo\n" + ); + } + + #[gpui::test] + async fn test_infer_external_created_file_edits_from_tool_call_locations( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection.new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) + }) + .await + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "test", + acp::ToolCallUpdateFields::new() + .locations(vec![acp::ToolCallLocation::new(path!("/test/new.txt"))]), + )), + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + fs.insert_file(path!("/test/new.txt"), "hello\n".as_bytes().to_vec()) + .await; + cx.run_until_parked(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "test", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), + cx, + ) + }) + .unwrap(); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + assert_eq!( + action_log.read_with(cx, |action_log, cx| action_log.changed_buffers(cx).len()), + 1 + ); + + action_log + .update(cx, |action_log, cx| action_log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert!(fs.read_file_sync(path!("/test/new.txt")).is_err()); + } + #[gpui::test(iterations = 10)] async fn test_checkpoints(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 3faf767c7020763eadc7db6c93af42f650a07434..d2879276403f27a3467f82825e89795b682f2c97 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -615,6 +615,135 @@ impl ActionLog { tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); } + fn prime_tracked_buffer_from_snapshot( + &mut self, + buffer: Entity, + baseline_snapshot: text::BufferSnapshot, + status: TrackedBufferStatus, + cx: &mut Context, + ) { + let version = buffer.read(cx).version(); + let diff_base = match &status { + TrackedBufferStatus::Created { .. } => Rope::default(), + TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => { + baseline_snapshot.as_rope().clone() + } + }; + + let tracked_buffer = self.track_buffer_internal(buffer, false, cx); + tracked_buffer.diff_base = diff_base; + tracked_buffer.snapshot = baseline_snapshot; + tracked_buffer.unreviewed_edits.clear(); + tracked_buffer.status = status; + tracked_buffer.version = version; + } + + pub fn has_changed_buffer(&self, buffer: &Entity, cx: &App) -> bool { + self.tracked_buffers + .get(buffer) + .is_some_and(|tracked_buffer| tracked_buffer.has_edits(cx)) + } + + pub fn infer_buffer_created( + &mut self, + buffer: Entity, + baseline_snapshot: text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + let linked_baseline_snapshot = baseline_snapshot.clone(); + if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) { + linked_action_log.update(cx, |log, cx| { + log.infer_buffer_created(buffer.clone(), linked_baseline_snapshot, cx); + }); + } + } + + self.update_file_read_time(&buffer, cx); + self.prime_tracked_buffer_from_snapshot( + buffer.clone(), + baseline_snapshot, + TrackedBufferStatus::Created { + existing_file_content: None, + }, + cx, + ); + + if let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) { + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } + } + + pub fn infer_buffer_edited_from_snapshot( + &mut self, + buffer: Entity, + baseline_snapshot: text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + let linked_baseline_snapshot = baseline_snapshot.clone(); + if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) { + linked_action_log.update(cx, |log, cx| { + log.infer_buffer_edited_from_snapshot( + buffer.clone(), + linked_baseline_snapshot, + cx, + ); + }); + } + } + + self.update_file_read_time(&buffer, cx); + self.prime_tracked_buffer_from_snapshot( + buffer.clone(), + baseline_snapshot, + TrackedBufferStatus::Modified, + cx, + ); + + if let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) { + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } + } + + pub fn infer_buffer_deleted_from_snapshot( + &mut self, + buffer: Entity, + baseline_snapshot: text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + let linked_baseline_snapshot = baseline_snapshot.clone(); + if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) { + linked_action_log.update(cx, |log, cx| { + log.infer_buffer_deleted_from_snapshot( + buffer.clone(), + linked_baseline_snapshot, + cx, + ); + }); + } + } + + self.remove_file_read_time(&buffer, cx); + let has_linked_action_log = self.linked_action_log.is_some(); + self.prime_tracked_buffer_from_snapshot( + buffer.clone(), + baseline_snapshot, + TrackedBufferStatus::Deleted, + cx, + ); + + if !has_linked_action_log { + buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); + } + + if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) { + tracked_buffer.version = buffer.read(cx).version(); + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } + } + pub fn will_delete_buffer(&mut self, buffer: Entity, cx: &mut Context) { // Ok to propagate file read time removal to linked action log self.remove_file_read_time(&buffer, cx); @@ -640,8 +769,11 @@ impl ActionLog { linked_action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); } - if has_linked_action_log && let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) { - tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) { + tracked_buffer.version = buffer.read(cx).version(); + if has_linked_action_log { + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } } cx.notify(); @@ -799,19 +931,27 @@ impl ActionLog { task } TrackedBufferStatus::Deleted => { - buffer.update(cx, |buffer, cx| { - buffer.set_text(tracked_buffer.diff_base.to_string(), cx) - }); - let save = self - .project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); + let current_version = buffer.read(cx).version(); + if current_version != tracked_buffer.version { + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); + self.tracked_buffers.remove(&buffer); + cx.notify(); + Task::ready(Ok(())) + } else { + buffer.update(cx, |buffer, cx| { + buffer.set_text(tracked_buffer.diff_base.to_string(), cx) + }); + let save = self + .project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); - // Clear all tracked edits for this buffer and start over as if we just read it. - metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); - self.tracked_buffers.remove(&buffer); - self.buffer_read(buffer.clone(), cx); - cx.notify(); - save + // Clear all tracked edits for this buffer and start over as if we just read it. + metrics.add_edits(tracked_buffer.unreviewed_edits.edits()); + self.tracked_buffers.remove(&buffer); + self.buffer_read(buffer.clone(), cx); + cx.notify(); + save + } } TrackedBufferStatus::Modified => { let edits_to_restore = buffer.update(cx, |buffer, cx| { @@ -3293,6 +3433,366 @@ mod tests { ); } + #[gpui::test] + async fn test_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_edited_from_snapshot( + buffer.clone(), + baseline_snapshot.clone(), + cx, + ); + }); + }); + cx.run_until_parked(); + + assert!( + !unreviewed_hunks(&action_log, cx).is_empty(), + "inferred edit should produce reviewable hunks" + ); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "one\ntwo\n" + ); + } + + #[gpui::test] + async fn test_infer_buffer_created(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/new_file", cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx); + }); + }); + cx.run_until_parked(); + + assert!( + !unreviewed_hunks(&action_log, cx).is_empty(), + "inferred creation should produce reviewable hunks" + ); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert!(fs.read_file_sync(path!("/dir/new_file")).is_err()); + } + + #[gpui::test] + async fn test_infer_buffer_deleted_from_snapshot(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default()) + .await + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_deleted_from_snapshot( + buffer.clone(), + baseline_snapshot.clone(), + cx, + ); + }); + }); + cx.run_until_parked(); + + assert!( + !unreviewed_hunks(&action_log, cx).is_empty(), + "inferred deletion should produce reviewable hunks" + ); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(), + "hello\n" + ); + } + + #[gpui::test] + async fn test_infer_buffer_deleted_from_snapshot_preserves_later_user_edits_on_reject( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default()) + .await + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_deleted_from_snapshot( + buffer.clone(), + baseline_snapshot.clone(), + cx, + ); + }); + }); + cx.run_until_parked(); + + buffer.update(cx, |buffer, cx| buffer.append("world\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "world\n"); + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(), + "world\n" + ); + } + + #[gpui::test] + async fn test_will_delete_buffer_preserves_later_user_edits_on_reject(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.delete_file(file_path, false, cx)) + .unwrap() + .await + .unwrap(); + cx.run_until_parked(); + + buffer.update(cx, |buffer, cx| buffer.append("world\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "world\n"); + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(), + "world\n" + ); + } + + #[gpui::test] + async fn test_infer_buffer_edited_from_snapshot_preserves_later_user_edits( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_edited_from_snapshot( + buffer.clone(), + baseline_snapshot.clone(), + cx, + ); + }); + }); + cx.run_until_parked(); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "zero\n")], None, cx); + }); + cx.run_until_parked(); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "zero\none\ntwo\n" + ); + assert_eq!( + String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(), + "zero\none\ntwo\n" + ); + } + + #[gpui::test] + async fn test_infer_buffer_created_preserves_later_user_edits_on_reject( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/new_file", cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| { + log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx); + }); + }); + cx.run_until_parked(); + + buffer.update(cx, |buffer, cx| buffer.append("world\n", cx)); + cx.run_until_parked(); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "hello\nworld\n" + ); + assert!(fs.read_file_sync(path!("/dir/new_file")).is_ok()); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + #[derive(Debug, PartialEq)] struct HunkStatus { range: Range,