| @@ -9,10 +9,15 @@ use collections::HashSet; · 8 unmodified lines |
| 9 | 9 | pub use connection::*; |
| 10 | 10 | pub use diff::*; |
| 11 | 11 | use futures::{FutureExt, channel::oneshot, future::BoxFuture}; |
| 12 | | −use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; |
| 12 | +use gpui::{ |
| 13 | + AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, |
| 14 | + WeakEntity, |
| 15 | +}; |
| 13 | 16 | use itertools::Itertools; |
| 14 | 17 | use language::language_settings::FormatOnSave; |
| 15 | | −use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff}; |
| 18 | +use language::{ |
| 19 | + Anchor, Buffer, BufferEvent, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff, |
| 20 | +}; |
| 16 | 21 | use markdown::Markdown; |
| 17 | 22 | pub use mention::*; |
| 18 | 23 | use project::lsp_store::{FormatTrigger, LspFormatTarget}; |
| @@ -1013,29 +1018,27 @@ struct RunningTurn { · 994 unmodified lines |
| 1013 | 1018 | send_task: Task<()>, |
| 1014 | 1019 | } |
| 1015 | 1020 | |
| 1016 | | −#[derive(Clone)] |
| 1017 | | −struct InferredEditCandidate { |
| 1021 | +struct InferredEditCandidateReady { |
| 1022 | + nonce: u64, |
| 1018 | 1023 | buffer: Entity<Buffer>, |
| 1019 | 1024 | baseline_snapshot: text::BufferSnapshot, |
| 1020 | 1025 | existed_on_disk: bool, |
| 1021 | 1026 | was_dirty: bool, |
| 1027 | + observed_external_file_change: bool, |
| 1028 | + has_inferred_edit: bool, |
| 1029 | + _subscription: Subscription, |
| 1022 | 1030 | } |
| 1023 | 1031 | |
| 1024 | | −#[derive(Clone)] |
| 1025 | 1032 | enum InferredEditCandidateState { |
| 1026 | | − Pending { |
| 1027 | | − nonce: u64, |
| 1028 | | − }, |
| 1029 | | − Ready { |
| 1030 | | − nonce: u64, |
| 1031 | | − candidate: InferredEditCandidate, |
| 1032 | | − }, |
| 1033 | + Pending { nonce: u64 }, |
| 1034 | + Ready(InferredEditCandidateReady), |
| 1033 | 1035 | } |
| 1034 | 1036 | |
| 1035 | 1037 | impl InferredEditCandidateState { |
| 1036 | 1038 | fn nonce(&self) -> u64 { |
| 1037 | 1039 | match self { |
| 1038 | | − Self::Pending { nonce } | Self::Ready { nonce, .. } => *nonce, |
| 1040 | + Self::Pending { nonce } => *nonce, |
| 1041 | + Self::Ready(candidate) => candidate.nonce, |
| 1039 | 1042 | } |
| 1040 | 1043 | } |
| 1041 | 1044 | } |
| @@ -1874,26 +1877,46 @@ impl AcpThread { · 832 unmodified lines |
| 1874 | 1877 | InferredEditCandidateState::Pending { nonce }, |
| 1875 | 1878 | ); |
| 1876 | 1879 | |
| 1877 | | − let project = self.project.clone(); |
| 1878 | | − let tool_call_id = tool_call_id.clone(); |
| 1879 | | − cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { |
| 1880 | | − let open_buffer = project.update(cx, |project, cx| { |
| 1881 | | − let project_path = project.project_path_for_absolute_path(&abs_path, cx)?; |
| 1882 | | − Some(project.open_buffer(project_path, cx)) |
| 1883 | | − }); |
| 1880 | + let open_buffer = self.project.update(cx, |project, cx| { |
| 1881 | + let project_path = project.project_path_for_absolute_path(&abs_path, cx)?; |
| 1882 | + if let Some(buffer) = project.get_open_buffer(&project_path, cx) { |
| 1883 | + let (baseline_snapshot, existed_on_disk, was_dirty) = |
| 1884 | + Self::inferred_edit_candidate_baseline(&buffer, cx); |
| 1885 | + return Some(( |
| 1886 | + Some((buffer, baseline_snapshot, existed_on_disk, was_dirty)), |
| 1887 | + None, |
| 1888 | + )); |
| 1889 | + } |
| 1884 | 1890 | |
| 1885 | | − let Some(open_buffer) = open_buffer else { |
| 1886 | | − this.update(cx, |this, _| { |
| 1887 | | − this.remove_inferred_edit_candidate_if_matching( |
| 1888 | | − &tool_call_id, |
| 1889 | | − &abs_path, |
| 1890 | | − nonce, |
| 1891 | | − ); |
| 1892 | | − }) |
| 1893 | | − .ok(); |
| 1894 | | − return Ok(()); |
| 1895 | | − }; |
| 1891 | + Some((None, Some(project.open_buffer(project_path, cx)))) |
| 1892 | + }); |
| 1896 | 1893 | |
| 1894 | + let Some((ready_candidate, open_buffer)) = open_buffer else { |
| 1895 | + self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce); |
| 1896 | + continue; |
| 1897 | + }; |
| 1898 | + |
| 1899 | + if let Some((buffer, baseline_snapshot, existed_on_disk, was_dirty)) = ready_candidate { |
| 1900 | + self.set_inferred_edit_candidate_ready( |
| 1901 | + tool_call_id.clone(), |
| 1902 | + abs_path, |
| 1903 | + nonce, |
| 1904 | + buffer, |
| 1905 | + baseline_snapshot, |
| 1906 | + existed_on_disk, |
| 1907 | + was_dirty, |
| 1908 | + cx, |
| 1909 | + ); |
| 1910 | + continue; |
| 1911 | + } |
| 1912 | + |
| 1913 | + let Some(open_buffer) = open_buffer else { |
| 1914 | + self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce); |
| 1915 | + continue; |
| 1916 | + }; |
| 1917 | + |
| 1918 | + let tool_call_id = tool_call_id.clone(); |
| 1919 | + cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { |
| 1897 | 1920 | let buffer = match open_buffer.await { |
| 1898 | 1921 | Ok(buffer) => buffer, |
| 1899 | 1922 | Err(_) => { |
| @@ -1910,34 +1933,19 @@ impl AcpThread { · 10 unmodified lines |
| 1910 | 1933 | }; |
| 1911 | 1934 | |
| 1912 | 1935 | let (baseline_snapshot, existed_on_disk, was_dirty) = |
| 1913 | | − buffer.read_with(cx, |buffer, _| { |
| 1914 | | − ( |
| 1915 | | − buffer.text_snapshot(), |
| 1916 | | − buffer.file().is_some_and(|file| file.disk_state().exists()), |
| 1917 | | − buffer.is_dirty(), |
| 1918 | | − ) |
| 1919 | | − }); |
| 1936 | + Self::inferred_edit_candidate_baseline(&buffer, cx); |
| 1920 | 1937 | |
| 1921 | | − this.update(cx, |this, _| { |
| 1922 | | − let Some(candidates) = this.inferred_edit_candidates.get_mut(&tool_call_id) |
| 1923 | | − else { |
| 1924 | | − return; |
| 1925 | | − }; |
| 1926 | | − let Some(candidate_state) = candidates.get_mut(&abs_path) else { |
| 1927 | | − return; |
| 1928 | | − }; |
| 1929 | | − if candidate_state.nonce() != nonce { |
| 1930 | | − return; |
| 1931 | | − } |
| 1932 | | − *candidate_state = InferredEditCandidateState::Ready { |
| 1938 | + this.update(cx, |this, cx| { |
| 1939 | + this.set_inferred_edit_candidate_ready( |
| 1940 | + tool_call_id.clone(), |
| 1941 | + abs_path.clone(), |
| 1933 | 1942 | nonce, |
| 1934 | | − candidate: InferredEditCandidate { |
| 1935 | | − buffer, |
| 1936 | | − baseline_snapshot, |
| 1937 | | − existed_on_disk, |
| 1938 | | − was_dirty, |
| 1939 | | − }, |
| 1940 | | − }; |
| 1943 | + buffer, |
| 1944 | + baseline_snapshot, |
| 1945 | + existed_on_disk, |
| 1946 | + was_dirty, |
| 1947 | + cx, |
| 1948 | + ); |
| 1941 | 1949 | }) |
| 1942 | 1950 | .ok(); |
| 1943 | 1951 | |
| @@ -1947,6 +1955,308 @@ impl AcpThread { · 3 unmodified lines |
| 1947 | 1955 | } |
| 1948 | 1956 | } |
| 1949 | 1957 | |
| 1958 | + fn inferred_edit_candidate_baseline( |
| 1959 | + buffer: &Entity<Buffer>, |
| 1960 | + cx: &mut impl AppContext, |
| 1961 | + ) -> (text::BufferSnapshot, bool, bool) { |
| 1962 | + buffer.read_with(cx, |buffer, _| { |
| 1963 | + ( |
| 1964 | + buffer.text_snapshot(), |
| 1965 | + buffer.file().is_some_and(|file| file.disk_state().exists()), |
| 1966 | + buffer.is_dirty(), |
| 1967 | + ) |
| 1968 | + }) |
| 1969 | + } |
| 1970 | + |
| 1971 | + fn update_ready_inferred_edit_candidate_if_matching( |
| 1972 | + &mut self, |
| 1973 | + tool_call_id: &acp::ToolCallId, |
| 1974 | + abs_path: &PathBuf, |
| 1975 | + nonce: u64, |
| 1976 | + update: impl FnOnce(&mut InferredEditCandidateReady), |
| 1977 | + ) -> bool { |
| 1978 | + let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) else { |
| 1979 | + return false; |
| 1980 | + }; |
| 1981 | + let Some(InferredEditCandidateState::Ready(candidate)) = candidates.get_mut(abs_path) |
| 1982 | + else { |
| 1983 | + return false; |
| 1984 | + }; |
| 1985 | + if candidate.nonce != nonce { |
| 1986 | + return false; |
| 1987 | + } |
| 1988 | + |
| 1989 | + update(candidate); |
| 1990 | + true |
| 1991 | + } |
| 1992 | + |
| 1993 | + fn set_inferred_edit_candidate_ready( |
| 1994 | + &mut self, |
| 1995 | + tool_call_id: acp::ToolCallId, |
| 1996 | + abs_path: PathBuf, |
| 1997 | + nonce: u64, |
| 1998 | + buffer: Entity<Buffer>, |
| 1999 | + baseline_snapshot: text::BufferSnapshot, |
| 2000 | + existed_on_disk: bool, |
| 2001 | + was_dirty: bool, |
| 2002 | + cx: &mut Context<Self>, |
| 2003 | + ) { |
| 2004 | + let subscription = cx.subscribe(&buffer, { |
| 2005 | + let tool_call_id = tool_call_id.clone(); |
| 2006 | + let abs_path = abs_path.clone(); |
| 2007 | + move |this, buffer, event: &BufferEvent, cx| { |
| 2008 | + this.handle_inferred_edit_candidate_buffer_event( |
| 2009 | + tool_call_id.clone(), |
| 2010 | + abs_path.clone(), |
| 2011 | + nonce, |
| 2012 | + buffer, |
| 2013 | + event, |
| 2014 | + cx, |
| 2015 | + ); |
| 2016 | + } |
| 2017 | + }); |
| 2018 | + |
| 2019 | + let Some(candidates) = self.inferred_edit_candidates.get_mut(&tool_call_id) else { |
| 2020 | + return; |
| 2021 | + }; |
| 2022 | + let Some(candidate_state) = candidates.get_mut(&abs_path) else { |
| 2023 | + return; |
| 2024 | + }; |
| 2025 | + if candidate_state.nonce() != nonce { |
| 2026 | + return; |
| 2027 | + } |
| 2028 | + |
| 2029 | + *candidate_state = InferredEditCandidateState::Ready(InferredEditCandidateReady { |
| 2030 | + nonce, |
| 2031 | + buffer, |
| 2032 | + baseline_snapshot, |
| 2033 | + existed_on_disk, |
| 2034 | + was_dirty, |
| 2035 | + observed_external_file_change: false, |
| 2036 | + has_inferred_edit: false, |
| 2037 | + _subscription: subscription, |
| 2038 | + }); |
| 2039 | + } |
| 2040 | + |
| 2041 | + fn can_infer_initial_external_edit_for_candidate( |
| 2042 | + &self, |
| 2043 | + buffer: &Entity<Buffer>, |
| 2044 | + was_dirty: bool, |
| 2045 | + cx: &App, |
| 2046 | + ) -> bool { |
| 2047 | + if was_dirty { |
| 2048 | + return false; |
| 2049 | + } |
| 2050 | + |
| 2051 | + if buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { |
| 2052 | + return false; |
| 2053 | + } |
| 2054 | + |
| 2055 | + !self.action_log.read_with(cx, |action_log, cx| { |
| 2056 | + action_log.has_changed_buffer(buffer, cx) |
| 2057 | + }) |
| 2058 | + } |
| 2059 | + |
| 2060 | + fn inferred_edit_candidate_has_meaningful_change( |
| 2061 | + existed_on_disk: bool, |
| 2062 | + baseline_snapshot: &text::BufferSnapshot, |
| 2063 | + buffer: &Entity<Buffer>, |
| 2064 | + cx: &App, |
| 2065 | + ) -> bool { |
| 2066 | + let (current_snapshot, current_exists) = buffer.read_with(cx, |buffer, _| { |
| 2067 | + ( |
| 2068 | + buffer.text_snapshot(), |
| 2069 | + buffer.file().is_some_and(|file| file.disk_state().exists()), |
| 2070 | + ) |
| 2071 | + }); |
| 2072 | + |
| 2073 | + if !existed_on_disk { |
| 2074 | + current_exists || current_snapshot.text() != baseline_snapshot.text() |
| 2075 | + } else { |
| 2076 | + current_snapshot.text() != baseline_snapshot.text() |
| 2077 | + } |
| 2078 | + } |
| 2079 | + |
| 2080 | + fn infer_inferred_edit_candidate_from_baseline( |
| 2081 | + &mut self, |
| 2082 | + buffer: Entity<Buffer>, |
| 2083 | + baseline_snapshot: text::BufferSnapshot, |
| 2084 | + existed_on_disk: bool, |
| 2085 | + cx: &mut Context<Self>, |
| 2086 | + ) { |
| 2087 | + if existed_on_disk { |
| 2088 | + self.action_log.update(cx, |action_log, cx| { |
| 2089 | + action_log.infer_buffer_edited_from_snapshot(buffer.clone(), baseline_snapshot, cx); |
| 2090 | + }); |
| 2091 | + } else { |
| 2092 | + self.action_log.update(cx, |action_log, cx| { |
| 2093 | + action_log.infer_buffer_created(buffer.clone(), baseline_snapshot, cx); |
| 2094 | + }); |
| 2095 | + } |
| 2096 | + } |
| 2097 | + |
| 2098 | + fn handle_inferred_edit_candidate_buffer_event( |
| 2099 | + &mut self, |
| 2100 | + tool_call_id: acp::ToolCallId, |
| 2101 | + abs_path: PathBuf, |
| 2102 | + nonce: u64, |
| 2103 | + buffer: Entity<Buffer>, |
| 2104 | + event: &BufferEvent, |
| 2105 | + cx: &mut Context<Self>, |
| 2106 | + ) { |
| 2107 | + let Some(InferredEditCandidateState::Ready(candidate)) = self |
| 2108 | + .inferred_edit_candidates |
| 2109 | + .get(&tool_call_id) |
| 2110 | + .and_then(|candidates| candidates.get(&abs_path)) |
| 2111 | + else { |
| 2112 | + return; |
| 2113 | + }; |
| 2114 | + if candidate.nonce != nonce { |
| 2115 | + return; |
| 2116 | + } |
| 2117 | + |
| 2118 | + let baseline_snapshot = candidate.baseline_snapshot.clone(); |
| 2119 | + let existed_on_disk = candidate.existed_on_disk; |
| 2120 | + let was_dirty = candidate.was_dirty; |
| 2121 | + let observed_external_file_change = candidate.observed_external_file_change; |
| 2122 | + let has_inferred_edit = candidate.has_inferred_edit; |
| 2123 | + |
| 2124 | + enum Action { |
| 2125 | + None, |
| 2126 | + Remove, |
| 2127 | + SetObservedExternalFileChange, |
| 2128 | + ClearObservedExternalFileChange, |
| 2129 | + ClearObservedAndTrackEdited, |
| 2130 | + ClearObservedAndInferCreatedOrEdited, |
| 2131 | + RemoveAndWillDeleteBuffer, |
| 2132 | + RemoveAndInferDeleted, |
| 2133 | + } |
| 2134 | + |
| 2135 | + let action = match event { |
| 2136 | + BufferEvent::Edited { is_local: true } |
| 2137 | + if !has_inferred_edit && !observed_external_file_change => |
| 2138 | + { |
| 2139 | + Action::Remove |
| 2140 | + } |
| 2141 | + BufferEvent::Saved if !has_inferred_edit => Action::Remove, |
| 2142 | + BufferEvent::FileHandleChanged => { |
| 2143 | + let is_deleted = buffer.read_with(cx, |buffer, _| { |
| 2144 | + buffer |
| 2145 | + .file() |
| 2146 | + .is_some_and(|file| file.disk_state().is_deleted()) |
| 2147 | + }); |
| 2148 | + |
| 2149 | + if is_deleted { |
| 2150 | + if has_inferred_edit { |
| 2151 | + Action::RemoveAndWillDeleteBuffer |
| 2152 | + } else if self |
| 2153 | + .can_infer_initial_external_edit_for_candidate(&buffer, was_dirty, cx) |
| 2154 | + { |
| 2155 | + Action::RemoveAndInferDeleted |
| 2156 | + } else { |
| 2157 | + Action::Remove |
| 2158 | + } |
| 2159 | + } else { |
| 2160 | + Action::SetObservedExternalFileChange |
| 2161 | + } |
| 2162 | + } |
| 2163 | + BufferEvent::Reloaded if observed_external_file_change => { |
| 2164 | + if has_inferred_edit { |
| 2165 | + Action::ClearObservedAndTrackEdited |
| 2166 | + } else if self.can_infer_initial_external_edit_for_candidate(&buffer, was_dirty, cx) |
| 2167 | + && Self::inferred_edit_candidate_has_meaningful_change( |
| 2168 | + existed_on_disk, |
| 2169 | + &baseline_snapshot, |
| 2170 | + &buffer, |
| 2171 | + cx, |
| 2172 | + ) |
| 2173 | + { |
| 2174 | + Action::ClearObservedAndInferCreatedOrEdited |
| 2175 | + } else { |
| 2176 | + Action::ClearObservedExternalFileChange |
| 2177 | + } |
| 2178 | + } |
| 2179 | + _ => Action::None, |
| 2180 | + }; |
| 2181 | + |
| 2182 | + match action { |
| 2183 | + Action::None => {} |
| 2184 | + Action::Remove => { |
| 2185 | + self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce); |
| 2186 | + } |
| 2187 | + Action::SetObservedExternalFileChange => { |
| 2188 | + self.update_ready_inferred_edit_candidate_if_matching( |
| 2189 | + &tool_call_id, |
| 2190 | + &abs_path, |
| 2191 | + nonce, |
| 2192 | + |candidate| { |
| 2193 | + candidate.observed_external_file_change = true; |
| 2194 | + }, |
| 2195 | + ); |
| 2196 | + } |
| 2197 | + Action::ClearObservedExternalFileChange => { |
| 2198 | + self.update_ready_inferred_edit_candidate_if_matching( |
| 2199 | + &tool_call_id, |
| 2200 | + &abs_path, |
| 2201 | + nonce, |
| 2202 | + |candidate| { |
| 2203 | + candidate.observed_external_file_change = false; |
| 2204 | + }, |
| 2205 | + ); |
| 2206 | + } |
| 2207 | + Action::ClearObservedAndTrackEdited => { |
| 2208 | + let updated = self.update_ready_inferred_edit_candidate_if_matching( |
| 2209 | + &tool_call_id, |
| 2210 | + &abs_path, |
| 2211 | + nonce, |
| 2212 | + |candidate| { |
| 2213 | + candidate.observed_external_file_change = false; |
| 2214 | + }, |
| 2215 | + ); |
| 2216 | + if updated { |
| 2217 | + self.action_log.update(cx, |action_log, cx| { |
| 2218 | + action_log.buffer_edited(buffer.clone(), cx); |
| 2219 | + }); |
| 2220 | + } |
| 2221 | + } |
| 2222 | + Action::ClearObservedAndInferCreatedOrEdited => { |
| 2223 | + let updated = self.update_ready_inferred_edit_candidate_if_matching( |
| 2224 | + &tool_call_id, |
| 2225 | + &abs_path, |
| 2226 | + nonce, |
| 2227 | + |candidate| { |
| 2228 | + candidate.observed_external_file_change = false; |
| 2229 | + candidate.has_inferred_edit = true; |
| 2230 | + }, |
| 2231 | + ); |
| 2232 | + if updated { |
| 2233 | + self.infer_inferred_edit_candidate_from_baseline( |
| 2234 | + buffer, |
| 2235 | + baseline_snapshot, |
| 2236 | + existed_on_disk, |
| 2237 | + cx, |
| 2238 | + ); |
| 2239 | + } |
| 2240 | + } |
| 2241 | + Action::RemoveAndWillDeleteBuffer => { |
| 2242 | + self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce); |
| 2243 | + self.action_log.update(cx, |action_log, cx| { |
| 2244 | + action_log.will_delete_buffer(buffer.clone(), cx); |
| 2245 | + }); |
| 2246 | + } |
| 2247 | + Action::RemoveAndInferDeleted => { |
| 2248 | + self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce); |
| 2249 | + self.action_log.update(cx, |action_log, cx| { |
| 2250 | + action_log.infer_buffer_deleted_from_snapshot( |
| 2251 | + buffer.clone(), |
| 2252 | + baseline_snapshot, |
| 2253 | + cx, |
| 2254 | + ); |
| 2255 | + }); |
| 2256 | + } |
| 2257 | + } |
| 2258 | + } |
| 2259 | + |
| 1950 | 2260 | fn finalize_inferred_edit_tool_call( |
| 1951 | 2261 | &mut self, |
| 1952 | 2262 | tool_call_id: acp::ToolCallId, |
| @@ -1969,187 +2279,71 @@ impl AcpThread { · 16 unmodified lines |
| 1969 | 2279 | } |
| 1970 | 2280 | |
| 1971 | 2281 | let project = self.project.clone(); |
| 1972 | | − let action_log = self.action_log.clone(); |
| 1973 | 2282 | cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { |
| 1974 | 2283 | const MAX_ATTEMPTS: usize = 3; |
| 1975 | 2284 | const ATTEMPT_DELAY: Duration = Duration::from_millis(50); |
| 1976 | 2285 | |
| 1977 | 2286 | for attempt in 0..MAX_ATTEMPTS { |
| 1978 | | − let (ready_candidates, has_pending) = this |
| 2287 | + let (buffers_to_reload, has_pending, has_observed_external_file_change) = this |
| 1979 | 2288 | .read_with(cx, |this, _| { |
| 1980 | 2289 | let Some(candidates) = this.inferred_edit_candidates.get(&tool_call_id) |
| 1981 | 2290 | else { |
| 1982 | | − return (Vec::new(), false); |
| 2291 | + return (Vec::new(), false, false); |
| 1983 | 2292 | }; |
| 1984 | 2293 | |
| 1985 | | − let mut ready_candidates = Vec::new(); |
| 2294 | + let mut buffers_to_reload = HashSet::default(); |
| 1986 | 2295 | let mut has_pending = false; |
| 1987 | | − for (abs_path, candidate_state) in candidates { |
| 2296 | + let mut has_observed_external_file_change = false; |
| 2297 | + |
| 2298 | + for candidate_state in candidates.values() { |
| 1988 | 2299 | match candidate_state { |
| 1989 | 2300 | InferredEditCandidateState::Pending { .. } => has_pending = true, |
| 1990 | | − InferredEditCandidateState::Ready { nonce, candidate } => { |
| 1991 | | − ready_candidates.push(( |
| 1992 | | − abs_path.clone(), |
| 1993 | | − *nonce, |
| 1994 | | − candidate.clone(), |
| 1995 | | − )); |
| 2301 | + InferredEditCandidateState::Ready(candidate) => { |
| 2302 | + if candidate.observed_external_file_change { |
| 2303 | + has_observed_external_file_change = true; |
| 2304 | + buffers_to_reload.insert(candidate.buffer.clone()); |
| 2305 | + } |
| 1996 | 2306 | } |
| 1997 | 2307 | } |
| 1998 | 2308 | } |
| 1999 | 2309 | |
| 2000 | | − (ready_candidates, has_pending) |
| 2310 | + ( |
| 2311 | + buffers_to_reload.into_iter().collect::<Vec<_>>(), |
| 2312 | + has_pending, |
| 2313 | + has_observed_external_file_change, |
| 2314 | + ) |
| 2001 | 2315 | }) |
| 2002 | | − .unwrap_or((Vec::new(), false)); |
| 2316 | + .unwrap_or((Vec::new(), false, false)); |
| 2003 | 2317 | |
| 2004 | | − if ready_candidates.is_empty() && !has_pending { |
| 2318 | + if buffers_to_reload.is_empty() && !has_pending { |
| 2005 | 2319 | break; |
| 2006 | 2320 | } |
| 2007 | 2321 | |
| 2008 | | − for (_, _, candidate) in &ready_candidates { |
| 2009 | | − let should_reload = candidate |
| 2010 | | − .buffer |
| 2011 | | − .read_with(cx, |buffer, _| !buffer.is_dirty()); |
| 2322 | + for buffer in buffers_to_reload { |
| 2323 | + let should_reload = buffer.read_with(cx, |buffer, _| !buffer.is_dirty()); |
| 2012 | 2324 | if !should_reload { |
| 2013 | 2325 | continue; |
| 2014 | 2326 | } |
| 2015 | 2327 | |
| 2016 | 2328 | let reload = project.update(cx, |project, cx| { |
| 2017 | 2329 | let mut buffers = HashSet::default(); |
| 2018 | | − buffers.insert(candidate.buffer.clone()); |
| 2330 | + buffers.insert(buffer.clone()); |
| 2019 | 2331 | project.reload_buffers(buffers, false, cx) |
| 2020 | 2332 | }); |
| 2021 | 2333 | reload.await.log_err(); |
| 2022 | 2334 | } |
| 2023 | 2335 | |
| 2024 | | − if !has_pending || attempt + 1 == MAX_ATTEMPTS { |
| 2336 | + if (!has_pending && !has_observed_external_file_change) |
| 2337 | + || attempt + 1 == MAX_ATTEMPTS |
| 2338 | + { |
| 2025 | 2339 | break; |
| 2026 | 2340 | } |
| 2027 | 2341 | |
| 2028 | 2342 | cx.background_executor().timer(ATTEMPT_DELAY).await; |
| 2029 | 2343 | } |
| 2030 | 2344 | |
| 2031 | | − let ready_candidates = this |
| 2032 | | − .read_with(cx, |this, _| { |
| 2033 | | − this.inferred_edit_candidates |
| 2034 | | − .get(&tool_call_id) |
| 2035 | | − .into_iter() |
| 2036 | | − .flat_map(|candidates| candidates.iter()) |
| 2037 | | − .filter_map(|(abs_path, candidate_state)| match candidate_state { |
| 2038 | | − InferredEditCandidateState::Pending { .. } => None, |
| 2039 | | − InferredEditCandidateState::Ready { nonce, candidate } => { |
| 2040 | | − Some((abs_path.clone(), *nonce, candidate.clone())) |
| 2041 | | − } |
| 2042 | | − }) |
| 2043 | | − .collect::<Vec<_>>() |
| 2044 | | − }) |
| 2045 | | − .unwrap_or_default(); |
| 2046 | | − |
| 2047 | | − let mut processed_candidates = Vec::new(); |
| 2048 | | − for (abs_path, nonce, candidate) in ready_candidates { |
| 2049 | | − let still_current = this |
| 2050 | | − .read_with(cx, |this, _| { |
| 2051 | | − this.inferred_edit_candidates |
| 2052 | | − .get(&tool_call_id) |
| 2053 | | − .and_then(|candidates| candidates.get(&abs_path)) |
| 2054 | | − .is_some_and(|candidate_state| candidate_state.nonce() == nonce) |
| 2055 | | − }) |
| 2056 | | − .unwrap_or(false); |
| 2057 | | − if !still_current { |
| 2058 | | − continue; |
| 2059 | | − } |
| 2060 | | − |
| 2061 | | − if candidate.was_dirty { |
| 2062 | | − processed_candidates.push((abs_path, nonce)); |
| 2063 | | − continue; |
| 2064 | | − } |
| 2065 | | − |
| 2066 | | − let already_changed = action_log.read_with(cx, |action_log, cx| { |
| 2067 | | − action_log.has_changed_buffer(&candidate.buffer, cx) |
| 2068 | | − }); |
| 2069 | | − if already_changed { |
| 2070 | | − processed_candidates.push((abs_path, nonce)); |
| 2071 | | − continue; |
| 2072 | | − } |
| 2073 | | − |
| 2074 | | − let (current_snapshot, current_exists, current_dirty) = |
| 2075 | | − candidate.buffer.read_with(cx, |buffer, _| { |
| 2076 | | − ( |
| 2077 | | − buffer.text_snapshot(), |
| 2078 | | − buffer.file().is_some_and(|file| file.disk_state().exists()), |
| 2079 | | − buffer.is_dirty(), |
| 2080 | | − ) |
| 2081 | | − }); |
| 2082 | | − |
| 2083 | | − if current_dirty { |
| 2084 | | − processed_candidates.push((abs_path, nonce)); |
| 2085 | | − continue; |
| 2086 | | − } |
| 2087 | | − |
| 2088 | | − let buffer_changed = current_snapshot.text() != candidate.baseline_snapshot.text(); |
| 2089 | | − |
| 2090 | | − if !candidate.existed_on_disk { |
| 2091 | | − if current_exists || buffer_changed { |
| 2092 | | − action_log.update(cx, |action_log, cx| { |
| 2093 | | − action_log.infer_buffer_created( |
| 2094 | | − candidate.buffer.clone(), |
| 2095 | | − candidate.baseline_snapshot.clone(), |
| 2096 | | − cx, |
| 2097 | | − ); |
| 2098 | | − }); |
| 2099 | | − } |
| 2100 | | − processed_candidates.push((abs_path, nonce)); |
| 2101 | | − } else if !current_exists { |
| 2102 | | − action_log.update(cx, |action_log, cx| { |
| 2103 | | − action_log.infer_buffer_deleted_from_snapshot( |
| 2104 | | − candidate.buffer.clone(), |
| 2105 | | − candidate.baseline_snapshot.clone(), |
| 2106 | | − cx, |
| 2107 | | − ); |
| 2108 | | − }); |
| 2109 | | − processed_candidates.push((abs_path, nonce)); |
| 2110 | | − } else if buffer_changed { |
| 2111 | | − action_log.update(cx, |action_log, cx| { |
| 2112 | | − action_log.infer_buffer_edited_from_snapshot( |
| 2113 | | − candidate.buffer.clone(), |
| 2114 | | − candidate.baseline_snapshot.clone(), |
| 2115 | | − cx, |
| 2116 | | − ); |
| 2117 | | − }); |
| 2118 | | − processed_candidates.push((abs_path, nonce)); |
| 2119 | | − } else { |
| 2120 | | − processed_candidates.push((abs_path, nonce)); |
| 2121 | | − } |
| 2122 | | − } |
| 2123 | | − |
| 2124 | | − this.update(cx, |this, cx| { |
| 2125 | | − for (abs_path, nonce) in processed_candidates { |
| 2126 | | − this.remove_inferred_edit_candidate_if_matching( |
| 2127 | | − &tool_call_id, |
| 2128 | | − &abs_path, |
| 2129 | | − nonce, |
| 2130 | | − ); |
| 2131 | | − } |
| 2132 | | − |
| 2133 | | − this.finalizing_inferred_edit_tool_calls |
| 2134 | | − .remove(&tool_call_id); |
| 2135 | | − |
| 2136 | | − let should_retry = this |
| 2137 | | − .inferred_edit_candidates |
| 2138 | | − .get(&tool_call_id) |
| 2139 | | − .is_some_and(|candidates| !candidates.is_empty()); |
| 2140 | | − |
| 2141 | | − if should_retry { |
| 2142 | | − let tool_call_id = tool_call_id.clone(); |
| 2143 | | − cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { |
| 2144 | | − cx.background_executor().timer(ATTEMPT_DELAY).await; |
| 2145 | | − this.update(cx, |this, cx| { |
| 2146 | | − this.finalize_inferred_edit_tool_call(tool_call_id, cx); |
| 2147 | | − }) |
| 2148 | | − .ok(); |
| 2149 | | − Ok(()) |
| 2150 | | − }) |
| 2151 | | − .detach_and_log_err(cx); |
| 2152 | | − } |
| 2345 | + this.update(cx, |this, _| { |
| 2346 | + this.clear_inferred_edit_candidates_for_tool_calls([tool_call_id.clone()]); |
| 2153 | 2347 | }) |
| 2154 | 2348 | .ok(); |
| 2155 | 2349 | |
| @@ -2695,7 +2889,10 @@ impl AcpThread { · 539 unmodified lines |
| 2695 | 2889 | |
| 2696 | 2890 | let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); |
| 2697 | 2891 | if canceled { |
| 2698 | | − this.mark_pending_tools_as_canceled(); |
| 2892 | + let canceled_tool_call_ids = this.mark_pending_tools_as_canceled(); |
| 2893 | + this.clear_inferred_edit_candidates_for_tool_calls( |
| 2894 | + canceled_tool_call_ids, |
| 2895 | + ); |
| 2699 | 2896 | this.finalize_all_inferred_edit_tool_calls(cx); |
| 2700 | 2897 | } |
| 2701 | 2898 | |
| @@ -2775,14 +2972,16 @@ impl AcpThread { · 73 unmodified lines |
| 2775 | 2972 | self.connection.cancel(&self.session_id, cx); |
| 2776 | 2973 | |
| 2777 | 2974 | Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); |
| 2778 | | − self.mark_pending_tools_as_canceled(); |
| 2975 | + let canceled_tool_call_ids = self.mark_pending_tools_as_canceled(); |
| 2976 | + self.clear_inferred_edit_candidates_for_tool_calls(canceled_tool_call_ids); |
| 2779 | 2977 | self.finalize_all_inferred_edit_tool_calls(cx); |
| 2780 | 2978 | |
| 2781 | | − // Wait for the send task to complete |
| 2782 | 2979 | cx.background_spawn(turn.send_task) |
| 2783 | 2980 | } |
| 2784 | 2981 | |
| 2785 | | − fn mark_pending_tools_as_canceled(&mut self) { |
| 2982 | + fn mark_pending_tools_as_canceled(&mut self) -> Vec<acp::ToolCallId> { |
| 2983 | + let mut canceled_tool_call_ids = Vec::new(); |
| 2984 | + |
| 2786 | 2985 | for entry in self.entries.iter_mut() { |
| 2787 | 2986 | if let AgentThreadEntry::ToolCall(call) = entry { |
| 2788 | 2987 | let cancel = matches!( |
| @@ -2794,9 +2993,12 @@ impl AcpThread { · 5 unmodified lines |
| 2794 | 2993 | |
| 2795 | 2994 | if cancel { |
| 2796 | 2995 | call.status = ToolCallStatus::Canceled; |
| 2996 | + canceled_tool_call_ids.push(call.id.clone()); |
| 2797 | 2997 | } |
| 2798 | 2998 | } |
| 2799 | 2999 | } |
| 3000 | + |
| 3001 | + canceled_tool_call_ids |
| 2800 | 3002 | } |
| 2801 | 3003 | |
| 2802 | 3004 | /// Restores the git working tree to the state at the given checkpoint (if one exists) |
| @@ -4494,6 +4696,369 @@ mod tests { · 1691 unmodified lines |
| 4494 | 4696 | assert!(fs.read_file_sync(path!("/test/new.txt")).is_err()); |
| 4495 | 4697 | } |
| 4496 | 4698 | |
| 4699 | + fn start_external_edit_tool_call( |
| 4700 | + thread: &Entity<AcpThread>, |
| 4701 | + tool_call_id: &acp::ToolCallId, |
| 4702 | + locations: Vec<acp::ToolCallLocation>, |
| 4703 | + cx: &mut TestAppContext, |
| 4704 | + ) { |
| 4705 | + let tool_call_id = tool_call_id.clone(); |
| 4706 | + thread |
| 4707 | + .update(cx, move |thread, cx| { |
| 4708 | + thread.handle_session_update( |
| 4709 | + acp::SessionUpdate::ToolCall( |
| 4710 | + acp::ToolCall::new(tool_call_id, "Label") |
| 4711 | + .kind(acp::ToolKind::Edit) |
| 4712 | + .status(acp::ToolCallStatus::InProgress) |
| 4713 | + .locations(locations), |
| 4714 | + ), |
| 4715 | + cx, |
| 4716 | + ) |
| 4717 | + }) |
| 4718 | + .unwrap(); |
| 4719 | + } |
| 4720 | + |
| 4721 | + fn update_external_edit_tool_call_locations( |
| 4722 | + thread: &Entity<AcpThread>, |
| 4723 | + tool_call_id: &acp::ToolCallId, |
| 4724 | + locations: Vec<acp::ToolCallLocation>, |
| 4725 | + cx: &mut TestAppContext, |
| 4726 | + ) { |
| 4727 | + let tool_call_id = tool_call_id.clone(); |
| 4728 | + thread |
| 4729 | + .update(cx, move |thread, cx| { |
| 4730 | + thread.handle_session_update( |
| 4731 | + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( |
| 4732 | + tool_call_id, |
| 4733 | + acp::ToolCallUpdateFields::new().locations(locations), |
| 4734 | + )), |
| 4735 | + cx, |
| 4736 | + ) |
| 4737 | + }) |
| 4738 | + .unwrap(); |
| 4739 | + } |
| 4740 | + |
| 4741 | + fn complete_external_edit_tool_call( |
| 4742 | + thread: &Entity<AcpThread>, |
| 4743 | + tool_call_id: &acp::ToolCallId, |
| 4744 | + cx: &mut TestAppContext, |
| 4745 | + ) { |
| 4746 | + let tool_call_id = tool_call_id.clone(); |
| 4747 | + thread |
| 4748 | + .update(cx, move |thread, cx| { |
| 4749 | + thread.handle_session_update( |
| 4750 | + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( |
| 4751 | + tool_call_id, |
| 4752 | + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), |
| 4753 | + )), |
| 4754 | + cx, |
| 4755 | + ) |
| 4756 | + }) |
| 4757 | + .unwrap(); |
| 4758 | + } |
| 4759 | + |
| 4760 | + fn changed_buffer_count(thread: &Entity<AcpThread>, cx: &TestAppContext) -> usize { |
| 4761 | + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); |
| 4762 | + action_log.read_with(cx, |action_log, cx| action_log.changed_buffers(cx).len()) |
| 4763 | + } |
| 4764 | + |
| 4765 | + fn inferred_edit_candidate_count(thread: &Entity<AcpThread>, cx: &TestAppContext) -> usize { |
| 4766 | + thread.read_with(cx, |thread, _| { |
| 4767 | + thread |
| 4768 | + .inferred_edit_candidates |
| 4769 | + .values() |
| 4770 | + .map(|candidates| candidates.len()) |
| 4771 | + .sum() |
| 4772 | + }) |
| 4773 | + } |
| 4774 | + |
| 4775 | + fn inferred_edit_candidate_is_ready( |
| 4776 | + thread: &Entity<AcpThread>, |
| 4777 | + tool_call_id: &acp::ToolCallId, |
| 4778 | + abs_path: &PathBuf, |
| 4779 | + cx: &TestAppContext, |
| 4780 | + ) -> bool { |
| 4781 | + thread.read_with(cx, |thread, _| { |
| 4782 | + thread |
| 4783 | + .inferred_edit_candidates |
| 4784 | + .get(tool_call_id) |
| 4785 | + .and_then(|candidates| candidates.get(abs_path)) |
| 4786 | + .is_some_and(|candidate_state| { |
| 4787 | + matches!(candidate_state, InferredEditCandidateState::Ready(_)) |
| 4788 | + }) |
| 4789 | + }) |
| 4790 | + } |
| 4791 | + |
| 4792 | + async fn open_test_buffer( |
| 4793 | + project: &Entity<Project>, |
| 4794 | + abs_path: &Path, |
| 4795 | + cx: &mut TestAppContext, |
| 4796 | + ) -> Entity<Buffer> { |
| 4797 | + project |
| 4798 | + .update(cx, |project, cx| { |
| 4799 | + let project_path = project |
| 4800 | + .project_path_for_absolute_path(abs_path, cx) |
| 4801 | + .unwrap(); |
| 4802 | + project.open_buffer(project_path, cx) |
| 4803 | + }) |
| 4804 | + .await |
| 4805 | + .unwrap() |
| 4806 | + } |
| 4807 | + |
| 4808 | + #[gpui::test] |
| 4809 | + async fn test_cancel_clears_inferred_candidate_state_for_external_edit_calls_with_locations( |
| 4810 | + cx: &mut TestAppContext, |
| 4811 | + ) { |
| 4812 | + init_test(cx); |
| 4813 | + let fs = FakeFs::new(cx.background_executor.clone()); |
| 4814 | + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) |
| 4815 | + .await; |
| 4816 | + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; |
| 4817 | + let connection = Rc::new(FakeAgentConnection::new()); |
| 4818 | + |
| 4819 | + let thread = cx |
| 4820 | + .update(|cx| { |
| 4821 | + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) |
| 4822 | + }) |
| 4823 | + .await |
| 4824 | + .unwrap(); |
| 4825 | + |
| 4826 | + let tool_call_id = acp::ToolCallId::new("test"); |
| 4827 | + start_external_edit_tool_call( |
| 4828 | + &thread, |
| 4829 | + &tool_call_id, |
| 4830 | + vec![acp::ToolCallLocation::new(path!("/test/file.txt"))], |
| 4831 | + cx, |
| 4832 | + ); |
| 4833 | + cx.run_until_parked(); |
| 4834 | + |
| 4835 | + assert_eq!(inferred_edit_candidate_count(&thread, cx), 1); |
| 4836 | + |
| 4837 | + let cancel = thread.update(cx, |thread, cx| { |
| 4838 | + thread.running_turn = Some(RunningTurn { |
| 4839 | + id: 1, |
| 4840 | + send_task: Task::ready(()), |
| 4841 | + }); |
| 4842 | + thread.cancel(cx) |
| 4843 | + }); |
| 4844 | + cancel.await; |
| 4845 | + cx.run_until_parked(); |
| 4846 | + |
| 4847 | + assert_eq!(inferred_edit_candidate_count(&thread, cx), 0); |
| 4848 | + thread.read_with(cx, |thread, _| { |
| 4849 | + let (_, tool_call) = thread.tool_call(&tool_call_id).unwrap(); |
| 4850 | + assert!(matches!(tool_call.status, ToolCallStatus::Canceled)); |
| 4851 | + }); |
| 4852 | + } |
| 4853 | + |
| 4854 | + #[gpui::test] |
| 4855 | + async fn test_completed_update_after_cancel_does_not_reuse_stale_inferred_candidate_baseline( |
| 4856 | + cx: &mut TestAppContext, |
| 4857 | + ) { |
| 4858 | + init_test(cx); |
| 4859 | + let fs = FakeFs::new(cx.background_executor.clone()); |
| 4860 | + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) |
| 4861 | + .await; |
| 4862 | + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; |
| 4863 | + let connection = Rc::new(FakeAgentConnection::new()); |
| 4864 | + |
| 4865 | + let thread = cx |
| 4866 | + .update(|cx| { |
| 4867 | + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) |
| 4868 | + }) |
| 4869 | + .await |
| 4870 | + .unwrap(); |
| 4871 | + |
| 4872 | + let tool_call_id = acp::ToolCallId::new("test"); |
| 4873 | + start_external_edit_tool_call( |
| 4874 | + &thread, |
| 4875 | + &tool_call_id, |
| 4876 | + vec![acp::ToolCallLocation::new(path!("/test/file.txt"))], |
| 4877 | + cx, |
| 4878 | + ); |
| 4879 | + cx.run_until_parked(); |
| 4880 | + |
| 4881 | + let cancel = thread.update(cx, |thread, cx| { |
| 4882 | + thread.running_turn = Some(RunningTurn { |
| 4883 | + id: 1, |
| 4884 | + send_task: Task::ready(()), |
| 4885 | + }); |
| 4886 | + thread.cancel(cx) |
| 4887 | + }); |
| 4888 | + cancel.await; |
| 4889 | + cx.run_until_parked(); |
| 4890 | + |
| 4891 | + assert_eq!(inferred_edit_candidate_count(&thread, cx), 0); |
| 4892 | + |
| 4893 | + fs.save( |
| 4894 | + path!("/test/file.txt").as_ref(), |
| 4895 | + &"one\ntwo\nthree\n".into(), |
| 4896 | + Default::default(), |
| 4897 | + ) |
| 4898 | + .await |
| 4899 | + .unwrap(); |
| 4900 | + cx.run_until_parked(); |
| 4901 | + |
| 4902 | + complete_external_edit_tool_call(&thread, &tool_call_id, cx); |
| 4903 | + cx.executor().advance_clock(Duration::from_millis(200)); |
| 4904 | + cx.run_until_parked(); |
| 4905 | + |
| 4906 | + assert_eq!(changed_buffer_count(&thread, cx), 0); |
| 4907 | + assert_eq!(inferred_edit_candidate_count(&thread, cx), 0); |
| 4908 | + } |
| 4909 | + |
| 4910 | + #[gpui::test] |
| 4911 | + async fn test_user_edit_and_save_during_external_tool_call_does_not_infer_edit( |
| 4912 | + cx: &mut TestAppContext, |
| 4913 | + ) { |
| 4914 | + init_test(cx); |
| 4915 | + let fs = FakeFs::new(cx.background_executor.clone()); |
| 4916 | + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) |
| 4917 | + .await; |
| 4918 | + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; |
| 4919 | + let connection = Rc::new(FakeAgentConnection::new()); |
| 4920 | + |
| 4921 | + let thread = cx |
| 4922 | + .update(|cx| { |
| 4923 | + connection.new_session( |
| 4924 | + project.clone(), |
| 4925 | + PathList::new(&[Path::new(path!("/test"))]), |
| 4926 | + cx, |
| 4927 | + ) |
| 4928 | + }) |
| 4929 | + .await |
| 4930 | + .unwrap(); |
| 4931 | + |
| 4932 | + let tool_call_id = acp::ToolCallId::new("test"); |
| 4933 | + start_external_edit_tool_call( |
| 4934 | + &thread, |
| 4935 | + &tool_call_id, |
| 4936 | + vec![acp::ToolCallLocation::new(path!("/test/file.txt"))], |
| 4937 | + cx, |
| 4938 | + ); |
| 4939 | + cx.run_until_parked(); |
| 4940 | + |
| 4941 | + let buffer = open_test_buffer(&project, Path::new(path!("/test/file.txt")), cx).await; |
| 4942 | + buffer.update(cx, |buffer, cx| { |
| 4943 | + buffer.edit([(0..0, "zero\n")], None, cx); |
| 4944 | + }); |
| 4945 | + |
| 4946 | + project |
| 4947 | + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) |
| 4948 | + .await |
| 4949 | + .unwrap(); |
| 4950 | + cx.run_until_parked(); |
| 4951 | + |
| 4952 | + complete_external_edit_tool_call(&thread, &tool_call_id, cx); |
| 4953 | + cx.executor().advance_clock(Duration::from_millis(200)); |
| 4954 | + cx.run_until_parked(); |
| 4955 | + |
| 4956 | + assert_eq!(changed_buffer_count(&thread, cx), 0); |
| 4957 | + assert_eq!(inferred_edit_candidate_count(&thread, cx), 0); |
| 4958 | + } |
| 4959 | + |
| 4960 | + #[gpui::test] |
| 4961 | + async fn test_already_open_buffer_captures_inferred_edit_baseline_synchronously( |
| 4962 | + cx: &mut TestAppContext, |
| 4963 | + ) { |
| 4964 | + init_test(cx); |
| 4965 | + let fs = FakeFs::new(cx.background_executor.clone()); |
| 4966 | + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) |
| 4967 | + .await; |
| 4968 | + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; |
| 4969 | + let connection = Rc::new(FakeAgentConnection::new()); |
| 4970 | + |
| 4971 | + let thread = cx |
| 4972 | + .update(|cx| { |
| 4973 | + connection.new_session( |
| 4974 | + project.clone(), |
| 4975 | + PathList::new(&[Path::new(path!("/test"))]), |
| 4976 | + cx, |
| 4977 | + ) |
| 4978 | + }) |
| 4979 | + .await |
| 4980 | + .unwrap(); |
| 4981 | + |
| 4982 | + let abs_path = PathBuf::from(path!("/test/file.txt")); |
| 4983 | + let _buffer = open_test_buffer(&project, Path::new(path!("/test/file.txt")), cx).await; |
| 4984 | + |
| 4985 | + let tool_call_id = acp::ToolCallId::new("test"); |
| 4986 | + start_external_edit_tool_call(&thread, &tool_call_id, Vec::new(), cx); |
| 4987 | + update_external_edit_tool_call_locations( |
| 4988 | + &thread, |
| 4989 | + &tool_call_id, |
| 4990 | + vec![acp::ToolCallLocation::new(abs_path.clone())], |
| 4991 | + cx, |
| 4992 | + ); |
| 4993 | + |
| 4994 | + assert!(inferred_edit_candidate_is_ready( |
| 4995 | + &thread, |
| 4996 | + &tool_call_id, |
| 4997 | + &abs_path, |
| 4998 | + cx |
| 4999 | + )); |
| 5000 | + |
| 5001 | + fs.save( |
| 5002 | + path!("/test/file.txt").as_ref(), |
| 5003 | + &"one\ntwo\nthree\n".into(), |
| 5004 | + Default::default(), |
| 5005 | + ) |
| 5006 | + .await |
| 5007 | + .unwrap(); |
| 5008 | + cx.run_until_parked(); |
| 5009 | + |
| 5010 | + complete_external_edit_tool_call(&thread, &tool_call_id, cx); |
| 5011 | + cx.executor().advance_clock(Duration::from_millis(200)); |
| 5012 | + cx.run_until_parked(); |
| 5013 | + |
| 5014 | + assert_eq!(changed_buffer_count(&thread, cx), 1); |
| 5015 | + } |
| 5016 | + |
| 5017 | + #[gpui::test] |
| 5018 | + async fn test_location_registration_after_external_write_does_not_infer_without_prior_baseline( |
| 5019 | + cx: &mut TestAppContext, |
| 5020 | + ) { |
| 5021 | + init_test(cx); |
| 5022 | + let fs = FakeFs::new(cx.background_executor.clone()); |
| 5023 | + fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"})) |
| 5024 | + .await; |
| 5025 | + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; |
| 5026 | + let connection = Rc::new(FakeAgentConnection::new()); |
| 5027 | + |
| 5028 | + let thread = cx |
| 5029 | + .update(|cx| { |
| 5030 | + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) |
| 5031 | + }) |
| 5032 | + .await |
| 5033 | + .unwrap(); |
| 5034 | + |
| 5035 | + let tool_call_id = acp::ToolCallId::new("test"); |
| 5036 | + start_external_edit_tool_call(&thread, &tool_call_id, Vec::new(), cx); |
| 5037 | + |
| 5038 | + fs.save( |
| 5039 | + path!("/test/file.txt").as_ref(), |
| 5040 | + &"one\ntwo\nthree\n".into(), |
| 5041 | + Default::default(), |
| 5042 | + ) |
| 5043 | + .await |
| 5044 | + .unwrap(); |
| 5045 | + cx.run_until_parked(); |
| 5046 | + |
| 5047 | + update_external_edit_tool_call_locations( |
| 5048 | + &thread, |
| 5049 | + &tool_call_id, |
| 5050 | + vec![acp::ToolCallLocation::new(path!("/test/file.txt"))], |
| 5051 | + cx, |
| 5052 | + ); |
| 5053 | + cx.run_until_parked(); |
| 5054 | + |
| 5055 | + complete_external_edit_tool_call(&thread, &tool_call_id, cx); |
| 5056 | + cx.executor().advance_clock(Duration::from_millis(200)); |
| 5057 | + cx.run_until_parked(); |
| 5058 | + |
| 5059 | + assert_eq!(changed_buffer_count(&thread, cx), 0); |
| 5060 | + } |
| 5061 | + |
| 4497 | 5062 | #[gpui::test(iterations = 10)] |
| 4498 | 5063 | async fn test_checkpoints(cx: &mut TestAppContext) { |
| 4499 | 5064 | init_test(cx); |