History

refactor

Ben Brandt committed 7 months ago

Commit 0761df21 parent 03f91712 files · +1115 −209Browse files

Unified · Split

2 files
  1. crates/acp_thread/src/acp_thread.rs +768 −203
  2. crates/action_log/src/action_log.rs +347 −6
crates/acp_thread/src/acp_thread.rs+768−203
@@ -9,10 +9,15 @@ use collections::HashSet; · 8 unmodified lines
99 pub use connection::*;
1010 pub use diff::*;
1111 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+};
1316 use itertools::Itertools;
1417 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+};
1621 use markdown::Markdown;
1722 pub use mention::*;
1823 use project::lsp_store::{FormatTrigger, LspFormatTarget};
@@ -1013,29 +1018,27 @@ struct RunningTurn { · 994 unmodified lines
10131018 send_task: Task<()>,
10141019 }
10151020
1016−#[derive(Clone)]
1017−struct InferredEditCandidate {
1021+struct InferredEditCandidateReady {
1022+ nonce: u64,
10181023 buffer: Entity<Buffer>,
10191024 baseline_snapshot: text::BufferSnapshot,
10201025 existed_on_disk: bool,
10211026 was_dirty: bool,
1027+ observed_external_file_change: bool,
1028+ has_inferred_edit: bool,
1029+ _subscription: Subscription,
10221030 }
10231031
1024−#[derive(Clone)]
10251032 enum InferredEditCandidateState {
1026− Pending {
1027− nonce: u64,
1028− },
1029− Ready {
1030− nonce: u64,
1031− candidate: InferredEditCandidate,
1032− },
1033+ Pending { nonce: u64 },
1034+ Ready(InferredEditCandidateReady),
10331035 }
10341036
10351037 impl InferredEditCandidateState {
10361038 fn nonce(&self) -> u64 {
10371039 match self {
1038− Self::Pending { nonce } | Self::Ready { nonce, .. } => *nonce,
1040+ Self::Pending { nonce } => *nonce,
1041+ Self::Ready(candidate) => candidate.nonce,
10391042 }
10401043 }
10411044 }
@@ -1874,26 +1877,46 @@ impl AcpThread { · 832 unmodified lines
18741877 InferredEditCandidateState::Pending { nonce },
18751878 );
18761879
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+ }
18841890
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+ });
18961893
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| {
18971920 let buffer = match open_buffer.await {
18981921 Ok(buffer) => buffer,
18991922 Err(_) => {
@@ -1910,34 +1933,19 @@ impl AcpThread { · 10 unmodified lines
19101933 };
19111934
19121935 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);
19201937
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(),
19331942 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+ );
19411949 })
19421950 .ok();
19431951
@@ -1947,6 +1955,308 @@ impl AcpThread { · 3 unmodified lines
19471955 }
19481956 }
19491957
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+
19502260 fn finalize_inferred_edit_tool_call(
19512261 &mut self,
19522262 tool_call_id: acp::ToolCallId,
@@ -1969,187 +2279,71 @@ impl AcpThread { · 16 unmodified lines
19692279 }
19702280
19712281 let project = self.project.clone();
1972− let action_log = self.action_log.clone();
19732282 cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| {
19742283 const MAX_ATTEMPTS: usize = 3;
19752284 const ATTEMPT_DELAY: Duration = Duration::from_millis(50);
19762285
19772286 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
19792288 .read_with(cx, |this, _| {
19802289 let Some(candidates) = this.inferred_edit_candidates.get(&tool_call_id)
19812290 else {
1982− return (Vec::new(), false);
2291+ return (Vec::new(), false, false);
19832292 };
19842293
1985− let mut ready_candidates = Vec::new();
2294+ let mut buffers_to_reload = HashSet::default();
19862295 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() {
19882299 match candidate_state {
19892300 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+ }
19962306 }
19972307 }
19982308 }
19992309
2000− (ready_candidates, has_pending)
2310+ (
2311+ buffers_to_reload.into_iter().collect::<Vec<_>>(),
2312+ has_pending,
2313+ has_observed_external_file_change,
2314+ )
20012315 })
2002− .unwrap_or((Vec::new(), false));
2316+ .unwrap_or((Vec::new(), false, false));
20032317
2004− if ready_candidates.is_empty() && !has_pending {
2318+ if buffers_to_reload.is_empty() && !has_pending {
20052319 break;
20062320 }
20072321
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());
20122324 if !should_reload {
20132325 continue;
20142326 }
20152327
20162328 let reload = project.update(cx, |project, cx| {
20172329 let mut buffers = HashSet::default();
2018− buffers.insert(candidate.buffer.clone());
2330+ buffers.insert(buffer.clone());
20192331 project.reload_buffers(buffers, false, cx)
20202332 });
20212333 reload.await.log_err();
20222334 }
20232335
2024− if !has_pending || attempt + 1 == MAX_ATTEMPTS {
2336+ if (!has_pending && !has_observed_external_file_change)
2337+ || attempt + 1 == MAX_ATTEMPTS
2338+ {
20252339 break;
20262340 }
20272341
20282342 cx.background_executor().timer(ATTEMPT_DELAY).await;
20292343 }
20302344
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()]);
21532347 })
21542348 .ok();
21552349
@@ -2695,7 +2889,10 @@ impl AcpThread { · 539 unmodified lines
26952889
26962890 let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled);
26972891 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+ );
26992896 this.finalize_all_inferred_edit_tool_calls(cx);
27002897 }
27012898
@@ -2775,14 +2972,16 @@ impl AcpThread { · 73 unmodified lines
27752972 self.connection.cancel(&self.session_id, cx);
27762973
27772974 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);
27792977 self.finalize_all_inferred_edit_tool_calls(cx);
27802978
2781− // Wait for the send task to complete
27822979 cx.background_spawn(turn.send_task)
27832980 }
27842981
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+
27862985 for entry in self.entries.iter_mut() {
27872986 if let AgentThreadEntry::ToolCall(call) = entry {
27882987 let cancel = matches!(
@@ -2794,9 +2993,12 @@ impl AcpThread { · 5 unmodified lines
27942993
27952994 if cancel {
27962995 call.status = ToolCallStatus::Canceled;
2996+ canceled_tool_call_ids.push(call.id.clone());
27972997 }
27982998 }
27992999 }
3000+
3001+ canceled_tool_call_ids
28003002 }
28013003
28023004 /// Restores the git working tree to the state at the given checkpoint (if one exists)
@@ -4494,6 +4696,369 @@ mod tests { · 1691 unmodified lines
44944696 assert!(fs.read_file_sync(path!("/test/new.txt")).is_err());
44954697 }
44964698
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+
44975062 #[gpui::test(iterations = 10)]
44985063 async fn test_checkpoints(cx: &mut TestAppContext) {
44995064 init_test(cx);
crates/action_log/src/action_log.rs+347−6
@@ -649,17 +649,34 @@ impl ActionLog { · 648 unmodified lines
649649 buffer: Entity<Buffer>,
650650 baseline_snapshot: text::BufferSnapshot,
651651 cx: &mut Context<Self>,
652+ ) {
653+ self.infer_buffer_created_impl(buffer, baseline_snapshot, true, cx);
654+ }
655+
656+ fn infer_buffer_created_impl(
657+ &mut self,
658+ buffer: Entity<Buffer>,
659+ baseline_snapshot: text::BufferSnapshot,
660+ record_file_read_time: bool,
661+ cx: &mut Context<Self>,
652662 ) {
653663 if let Some(linked_action_log) = &self.linked_action_log {
654664 let linked_baseline_snapshot = baseline_snapshot.clone();
655665 if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
656666 linked_action_log.update(cx, |log, cx| {
657− log.infer_buffer_created(buffer.clone(), linked_baseline_snapshot, cx);
667+ log.infer_buffer_created_impl(
668+ buffer.clone(),
669+ linked_baseline_snapshot,
670+ false,
671+ cx,
672+ );
658673 });
659674 }
660675 }
661676
662− self.update_file_read_time(&buffer, cx);
677+ if record_file_read_time {
678+ self.update_file_read_time(&buffer, cx);
679+ }
663680 self.prime_tracked_buffer_from_snapshot(
664681 buffer.clone(),
665682 baseline_snapshot,
@@ -679,21 +696,34 @@ impl ActionLog { · 13 unmodified lines
679696 buffer: Entity<Buffer>,
680697 baseline_snapshot: text::BufferSnapshot,
681698 cx: &mut Context<Self>,
699+ ) {
700+ self.infer_buffer_edited_from_snapshot_impl(buffer, baseline_snapshot, true, cx);
701+ }
702+
703+ fn infer_buffer_edited_from_snapshot_impl(
704+ &mut self,
705+ buffer: Entity<Buffer>,
706+ baseline_snapshot: text::BufferSnapshot,
707+ record_file_read_time: bool,
708+ cx: &mut Context<Self>,
682709 ) {
683710 if let Some(linked_action_log) = &self.linked_action_log {
684711 let linked_baseline_snapshot = baseline_snapshot.clone();
685712 if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
686713 linked_action_log.update(cx, |log, cx| {
687− log.infer_buffer_edited_from_snapshot(
714+ log.infer_buffer_edited_from_snapshot_impl(
688715 buffer.clone(),
689716 linked_baseline_snapshot,
717+ false,
690718 cx,
691719 );
692720 });
693721 }
694722 }
695723
696− self.update_file_read_time(&buffer, cx);
724+ if record_file_read_time {
725+ self.update_file_read_time(&buffer, cx);
726+ }
697727 self.prime_tracked_buffer_from_snapshot(
698728 buffer.clone(),
699729 baseline_snapshot,
@@ -711,21 +741,34 @@ impl ActionLog { · 11 unmodified lines
711741 buffer: Entity<Buffer>,
712742 baseline_snapshot: text::BufferSnapshot,
713743 cx: &mut Context<Self>,
744+ ) {
745+ self.infer_buffer_deleted_from_snapshot_impl(buffer, baseline_snapshot, true, cx);
746+ }
747+
748+ fn infer_buffer_deleted_from_snapshot_impl(
749+ &mut self,
750+ buffer: Entity<Buffer>,
751+ baseline_snapshot: text::BufferSnapshot,
752+ record_file_read_time: bool,
753+ cx: &mut Context<Self>,
714754 ) {
715755 if let Some(linked_action_log) = &self.linked_action_log {
716756 let linked_baseline_snapshot = baseline_snapshot.clone();
717757 if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
718758 linked_action_log.update(cx, |log, cx| {
719− log.infer_buffer_deleted_from_snapshot(
759+ log.infer_buffer_deleted_from_snapshot_impl(
720760 buffer.clone(),
721761 linked_baseline_snapshot,
762+ false,
722763 cx,
723764 );
724765 });
725766 }
726767 }
727768
728− self.remove_file_read_time(&buffer, cx);
769+ if record_file_read_time {
770+ self.remove_file_read_time(&buffer, cx);
771+ }
729772 let has_linked_action_log = self.linked_action_log.is_some();
730773 self.prime_tracked_buffer_from_snapshot(
731774 buffer.clone(),
@@ -3433,6 +3476,304 @@ mod tests { · 2701 unmodified lines
34333476 );
34343477 }
34353478
3479+ #[gpui::test]
3480+ async fn test_file_read_time_not_forwarded_to_linked_action_log_for_inferred_edits(
3481+ cx: &mut TestAppContext,
3482+ ) {
3483+ init_test(cx);
3484+
3485+ let fs = FakeFs::new(cx.executor());
3486+ fs.insert_tree(
3487+ path!("/dir"),
3488+ json!({
3489+ "edit": "hello world\n",
3490+ "delete": "goodbye world\n",
3491+ }),
3492+ )
3493+ .await;
3494+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3495+ let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3496+ let child_log =
3497+ cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3498+
3499+ let edit_file_path = project
3500+ .read_with(cx, |project, cx| project.find_project_path("dir/edit", cx))
3501+ .unwrap();
3502+ let edit_buffer = project
3503+ .update(cx, |project, cx| project.open_buffer(edit_file_path, cx))
3504+ .await
3505+ .unwrap();
3506+ let edit_abs_path = PathBuf::from(path!("/dir/edit"));
3507+ let edit_baseline_snapshot = edit_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3508+
3509+ edit_buffer.update(cx, |buffer, cx| buffer.set_text("hello world!\n", cx));
3510+ project
3511+ .update(cx, |project, cx| {
3512+ project.save_buffer(edit_buffer.clone(), cx)
3513+ })
3514+ .await
3515+ .unwrap();
3516+
3517+ cx.update(|cx| {
3518+ child_log.update(cx, |log, cx| {
3519+ log.infer_buffer_edited_from_snapshot(
3520+ edit_buffer.clone(),
3521+ edit_baseline_snapshot.clone(),
3522+ cx,
3523+ );
3524+ });
3525+ });
3526+
3527+ assert!(
3528+ child_log.read_with(cx, |log, _| log.file_read_time(&edit_abs_path).is_some()),
3529+ "child should record file_read_time on inferred edit"
3530+ );
3531+ assert!(
3532+ parent_log.read_with(cx, |log, _| log.file_read_time(&edit_abs_path).is_none()),
3533+ "parent should NOT get file_read_time from child's inferred edit"
3534+ );
3535+
3536+ let create_file_path = project
3537+ .read_with(cx, |project, cx| {
3538+ project.find_project_path("dir/new_file", cx)
3539+ })
3540+ .unwrap();
3541+ let create_buffer = project
3542+ .update(cx, |project, cx| project.open_buffer(create_file_path, cx))
3543+ .await
3544+ .unwrap();
3545+ let create_abs_path = PathBuf::from(path!("/dir/new_file"));
3546+ let create_baseline_snapshot =
3547+ create_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3548+
3549+ create_buffer.update(cx, |buffer, cx| buffer.set_text("new file\n", cx));
3550+ project
3551+ .update(cx, |project, cx| {
3552+ project.save_buffer(create_buffer.clone(), cx)
3553+ })
3554+ .await
3555+ .unwrap();
3556+
3557+ cx.update(|cx| {
3558+ child_log.update(cx, |log, cx| {
3559+ log.infer_buffer_created(
3560+ create_buffer.clone(),
3561+ create_baseline_snapshot.clone(),
3562+ cx,
3563+ );
3564+ });
3565+ });
3566+
3567+ assert!(
3568+ child_log.read_with(cx, |log, _| log.file_read_time(&create_abs_path).is_some()),
3569+ "child should record file_read_time on inferred create"
3570+ );
3571+ assert!(
3572+ parent_log.read_with(cx, |log, _| log.file_read_time(&create_abs_path).is_none()),
3573+ "parent should NOT get file_read_time from child's inferred create"
3574+ );
3575+
3576+ let delete_file_path = project
3577+ .read_with(cx, |project, cx| {
3578+ project.find_project_path("dir/delete", cx)
3579+ })
3580+ .unwrap();
3581+ let delete_buffer = project
3582+ .update(cx, |project, cx| project.open_buffer(delete_file_path, cx))
3583+ .await
3584+ .unwrap();
3585+ let delete_abs_path = PathBuf::from(path!("/dir/delete"));
3586+ let delete_baseline_snapshot =
3587+ delete_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3588+
3589+ cx.update(|cx| {
3590+ parent_log.update(cx, |log, cx| log.buffer_read(delete_buffer.clone(), cx));
3591+ child_log.update(cx, |log, cx| log.buffer_read(delete_buffer.clone(), cx));
3592+ });
3593+
3594+ assert!(
3595+ parent_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
3596+ "parent should record its own file_read_time before inferred delete"
3597+ );
3598+ assert!(
3599+ child_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
3600+ "child should record its own file_read_time before inferred delete"
3601+ );
3602+
3603+ fs.remove_file(path!("/dir/delete").as_ref(), RemoveOptions::default())
3604+ .await
3605+ .unwrap();
3606+ cx.run_until_parked();
3607+
3608+ cx.update(|cx| {
3609+ child_log.update(cx, |log, cx| {
3610+ log.infer_buffer_deleted_from_snapshot(
3611+ delete_buffer.clone(),
3612+ delete_baseline_snapshot.clone(),
3613+ cx,
3614+ );
3615+ });
3616+ });
3617+
3618+ assert!(
3619+ child_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_none()),
3620+ "child should remove file_read_time on inferred delete"
3621+ );
3622+ assert!(
3623+ parent_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
3624+ "parent should keep its own file_read_time on linked inferred delete"
3625+ );
3626+ }
3627+
3628+ #[gpui::test]
3629+ async fn test_linked_action_log_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) {
3630+ init_test(cx);
3631+
3632+ let fs = FakeFs::new(cx.executor());
3633+ fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
3634+ .await;
3635+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3636+ let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3637+ let child_log =
3638+ cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3639+
3640+ let file_path = project
3641+ .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3642+ .unwrap();
3643+ let buffer = project
3644+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
3645+ .await
3646+ .unwrap();
3647+
3648+ let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3649+
3650+ buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
3651+ project
3652+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3653+ .await
3654+ .unwrap();
3655+
3656+ cx.update(|cx| {
3657+ child_log.update(cx, |log, cx| {
3658+ log.infer_buffer_edited_from_snapshot(
3659+ buffer.clone(),
3660+ baseline_snapshot.clone(),
3661+ cx,
3662+ );
3663+ });
3664+ });
3665+ cx.run_until_parked();
3666+
3667+ let child_hunks = unreviewed_hunks(&child_log, cx);
3668+ assert!(
3669+ !child_hunks.is_empty(),
3670+ "child should track the inferred edit"
3671+ );
3672+ assert_eq!(
3673+ unreviewed_hunks(&parent_log, cx),
3674+ child_hunks,
3675+ "parent should also track the inferred edit via linked log forwarding"
3676+ );
3677+ }
3678+
3679+ #[gpui::test]
3680+ async fn test_linked_action_log_infer_buffer_created(cx: &mut TestAppContext) {
3681+ init_test(cx);
3682+
3683+ let fs = FakeFs::new(cx.executor());
3684+ fs.insert_tree(path!("/dir"), json!({})).await;
3685+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3686+ let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3687+ let child_log =
3688+ cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3689+
3690+ let file_path = project
3691+ .read_with(cx, |project, cx| {
3692+ project.find_project_path("dir/new_file", cx)
3693+ })
3694+ .unwrap();
3695+ let buffer = project
3696+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
3697+ .await
3698+ .unwrap();
3699+
3700+ let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3701+
3702+ buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx));
3703+ project
3704+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3705+ .await
3706+ .unwrap();
3707+
3708+ cx.update(|cx| {
3709+ child_log.update(cx, |log, cx| {
3710+ log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx);
3711+ });
3712+ });
3713+ cx.run_until_parked();
3714+
3715+ let child_hunks = unreviewed_hunks(&child_log, cx);
3716+ assert!(
3717+ !child_hunks.is_empty(),
3718+ "child should track the inferred creation"
3719+ );
3720+ assert_eq!(
3721+ unreviewed_hunks(&parent_log, cx),
3722+ child_hunks,
3723+ "parent should also track the inferred creation via linked log forwarding"
3724+ );
3725+ }
3726+
3727+ #[gpui::test]
3728+ async fn test_linked_action_log_infer_buffer_deleted_from_snapshot(cx: &mut TestAppContext) {
3729+ init_test(cx);
3730+
3731+ let fs = FakeFs::new(cx.executor());
3732+ fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
3733+ .await;
3734+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3735+ let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3736+ let child_log =
3737+ cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3738+
3739+ let file_path = project
3740+ .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3741+ .unwrap();
3742+ let buffer = project
3743+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
3744+ .await
3745+ .unwrap();
3746+
3747+ let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3748+
3749+ fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default())
3750+ .await
3751+ .unwrap();
3752+ cx.run_until_parked();
3753+
3754+ cx.update(|cx| {
3755+ child_log.update(cx, |log, cx| {
3756+ log.infer_buffer_deleted_from_snapshot(
3757+ buffer.clone(),
3758+ baseline_snapshot.clone(),
3759+ cx,
3760+ );
3761+ });
3762+ });
3763+ cx.run_until_parked();
3764+
3765+ let child_hunks = unreviewed_hunks(&child_log, cx);
3766+ assert!(
3767+ !child_hunks.is_empty(),
3768+ "child should track the inferred deletion"
3769+ );
3770+ assert_eq!(
3771+ unreviewed_hunks(&parent_log, cx),
3772+ child_hunks,
3773+ "parent should also track the inferred deletion via linked log forwarding"
3774+ );
3775+ }
3776+
34363777 #[gpui::test]
34373778 async fn test_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) {
34383779 init_test(cx);