lattice_ai/acp/tool_fold.rs
1//! TCF: fold sources for the `*ai:opencode*` conversation buffer.
2//!
3//! Two [`lattice_core::FoldSource`]s, both reading the same single-pass
4//! layout ([`project_conversation`]) so a fold's line range can never drift
5//! from the rendered text:
6//!
7//! - [`ToolCallFoldSource`] — one fold per tool call that has captured
8//! `input`/`output` detail, spanning its `▸ summary [status]` head line
9//! through the last detail row.
10//! - [`ReasoningFoldSource`] — one fold per multi-line reasoning block.
11//!
12//! Both fold `closed: true` by default (a fresh tool call / reasoning block
13//! opens collapsed; `za` on the head line expands it). Lattice fold semantics
14//! keep the head line visible (`start_line < line <= end_line`, see
15//! `lattice_host::folds::FoldIndex::line_inside_closed_fold`), so a closed
16//! fold shows only the summary and elides the detail rows at the cell layer —
17//! a collapsed transcript costs the renderer nothing per hidden line
18//! (paramount #1).
19//!
20//! **Mode-owned**, exactly the shape `diff-mode` uses for [`HunkFoldSource`]:
21//! `AiConversationMode::on_activate` constructs one of each (holding a
22//! [`ConversationStore`] clone) and registers them via the
23//! `FoldOverlayService`; the mode's `Drop` guard removes them. Each holds the
24//! store, so `compute_folds` reads the currently-published conversation on
25//! every recompute — no `FoldContext`, no buffer round-trip.
26//!
27//! [`HunkFoldSource`]: crate — see `lattice_diff::fold::HunkFoldSource` for the
28//! precedent this mirrors.
29//!
30//! ## Identity — the streaming-coherence fix
31//!
32//! A closed fold's expansion state is carried across recomputes by
33//! [`lattice_core::Fold::identity`], not by its line range. The transcript
34//! grows above a tool call on every streamed token, shifting its line range;
35//! keying identity on the *tool-call id* (not the range) is what keeps an
36//! expanded call expanded as content lands above it. Reasoning blocks carry no
37//! wire id, so their identity is keyed on the block's *ordinal* in document
38//! order — stable because earlier turns are never rewritten, so a given
39//! reasoning block stays the nth one as it streams.
40
41use std::hash::{DefaultHasher, Hash, Hasher};
42
43use lattice_core::{BufferId, Fold, FoldSource, ProviderId};
44
45use crate::acp::conversation::ConversationStore;
46use crate::acp::conversation_mode::project_conversation;
47
48/// Namespace for the per-buffer tool-call fold provider id. OR'd with the
49/// buffer's id (low 32 bits) so the source is distinct in the registry —
50/// `FoldOverlayService::add_source` keys removal on the id. Distinct high bits
51/// from [`REASONING_FOLD_NAMESPACE`] so a buffer's two AI fold sources register
52/// under different ids (they coexist over disjoint line regions). Distinct from
53/// diff's `0xD1FF_*` and multibuffer's `0xBBBB_*` namespaces.
54pub const TOOL_FOLD_NAMESPACE: u64 = 0xA1F0_0001_0000_0000;
55
56/// Namespace for the per-buffer reasoning fold provider id. See
57/// [`TOOL_FOLD_NAMESPACE`].
58pub const REASONING_FOLD_NAMESPACE: u64 = 0xA1F0_0002_0000_0000;
59
60/// TCF: which kind of block a [`ConversationFold`] covers, so each
61/// [`FoldSource`] can filter [`project_conversation`]'s span list to its own.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ConversationFoldKind {
64 ToolCall,
65 Reasoning,
66}
67
68/// TCF: one foldable region in the projected transcript, produced by
69/// [`project_conversation`]. `start_line` is the fold head (stays visible when
70/// closed); `start_line + 1 ..= end_line` are the rows that hide. `identity`
71/// carries closed-state across streaming recomputes.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ConversationFold {
74 pub start_line: u32,
75 pub end_line: u32,
76 pub identity: u64,
77 pub kind: ConversationFoldKind,
78}
79
80/// Stable identity for a tool-call fold, keyed on the wire `tool_call_id` (not
81/// the line range) so the fold's expansion state survives a transcript that
82/// grows above it. Namespaced with `"ai:tool"` so it never collides with a
83/// primary provider's hash for the same span.
84pub fn tool_fold_identity(tool_call_id: &str) -> u64 {
85 let mut h = DefaultHasher::new();
86 "ai:tool".hash(&mut h);
87 tool_call_id.hash(&mut h);
88 h.finish()
89}
90
91/// Stable identity for a reasoning fold, keyed on the block's ordinal in
92/// document order (reasoning blocks carry no wire id). Namespaced with
93/// `"ai:reasoning"`.
94pub fn reasoning_fold_identity(ordinal: usize) -> u64 {
95 let mut h = DefaultHasher::new();
96 "ai:reasoning".hash(&mut h);
97 ordinal.hash(&mut h);
98 h.finish()
99}
100
101/// Snapshot the store, project it, and return the `closed`-by-default folds of
102/// `want` kind. Shared by both sources — the layout pass is the single source
103/// of truth for line ranges.
104fn folds_of_kind(store: &ConversationStore, want: ConversationFoldKind) -> Vec<Fold> {
105 let conv = store.snapshot();
106 let (_text, spans) = project_conversation(&conv);
107 spans
108 .into_iter()
109 .filter(|s| s.kind == want)
110 .map(|s| Fold {
111 start_line: s.start_line,
112 end_line: s.end_line,
113 closed: true,
114 identity: Some(s.identity),
115 })
116 .collect()
117}
118
119/// TCF: folds each tool call's captured detail rows (closed by default).
120pub struct ToolCallFoldSource {
121 id: ProviderId,
122 store: ConversationStore,
123}
124
125impl ToolCallFoldSource {
126 /// Build a source over `store`, namespaced by `buffer_id` so it is
127 /// distinct from the buffer's reasoning-fold source in the registry.
128 pub fn new(store: ConversationStore, buffer_id: BufferId) -> Self {
129 Self {
130 id: ProviderId(TOOL_FOLD_NAMESPACE | buffer_id.0 as u64),
131 store,
132 }
133 }
134}
135
136impl FoldSource for ToolCallFoldSource {
137 fn id(&self) -> ProviderId {
138 self.id
139 }
140 fn compute_folds(&self) -> Vec<Fold> {
141 folds_of_kind(&self.store, ConversationFoldKind::ToolCall)
142 }
143}
144
145/// TCF: folds each multi-line reasoning block (closed by default).
146pub struct ReasoningFoldSource {
147 id: ProviderId,
148 store: ConversationStore,
149}
150
151impl ReasoningFoldSource {
152 /// Build a source over `store`, namespaced by `buffer_id`.
153 pub fn new(store: ConversationStore, buffer_id: BufferId) -> Self {
154 Self {
155 id: ProviderId(REASONING_FOLD_NAMESPACE | buffer_id.0 as u64),
156 store,
157 }
158 }
159}
160
161impl FoldSource for ReasoningFoldSource {
162 fn id(&self) -> ProviderId {
163 self.id
164 }
165 fn compute_folds(&self) -> Vec<Fold> {
166 folds_of_kind(&self.store, ConversationFoldKind::Reasoning)
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 #![allow(clippy::unwrap_used, clippy::panic)]
173 use super::*;
174 use crate::acp::conversation::{Block, Conversation, Role, ToolStatus, Turn};
175 use std::sync::Arc;
176
177 /// A detailed tool call in an assistant turn preceded by `preamble` lines of
178 /// text — lets a test grow the content above the call to shift its range.
179 fn conv_with_tool_after(preamble: &str) -> Conversation {
180 Conversation {
181 turns: vec![Turn {
182 role: Role::Assistant,
183 blocks: vec![
184 Block::Text(preamble.to_string()),
185 Block::ToolCall {
186 id: "tc-1".to_string(),
187 title: "bash".to_string(),
188 status: ToolStatus::Ok,
189 kind: Default::default(),
190 input: Some("{\n \"cmd\": \"echo hi\"\n}".to_string()),
191 output: Some("\"hi\"".to_string()),
192 },
193 ],
194 }],
195 ..Default::default()
196 }
197 }
198
199 #[test]
200 fn tool_identity_stable_per_id_and_distinct_between_ids() {
201 assert_eq!(tool_fold_identity("tc-1"), tool_fold_identity("tc-1"));
202 assert_ne!(tool_fold_identity("tc-1"), tool_fold_identity("tc-2"));
203 }
204
205 #[test]
206 fn reasoning_identity_stable_per_ordinal_and_distinct_from_tool() {
207 assert_eq!(reasoning_fold_identity(0), reasoning_fold_identity(0));
208 assert_ne!(reasoning_fold_identity(0), reasoning_fold_identity(1));
209 // Namespaced away from tool identities so the same span never collides.
210 assert_ne!(reasoning_fold_identity(0), tool_fold_identity("tc-1"));
211 }
212
213 #[test]
214 fn provider_ids_are_namespaced_per_buffer_and_per_kind() {
215 let store = ConversationStore::new(Arc::new(|_| {}));
216 let tool = ToolCallFoldSource::new(store.clone(), BufferId(7));
217 let reasoning = ReasoningFoldSource::new(store, BufferId(7));
218 assert_eq!(tool.id(), ProviderId(TOOL_FOLD_NAMESPACE | 7));
219 assert_eq!(reasoning.id(), ProviderId(REASONING_FOLD_NAMESPACE | 7));
220 assert_ne!(
221 tool.id(),
222 reasoning.id(),
223 "distinct ids so removal is independent"
224 );
225 }
226
227 /// A tool call with captured detail yields exactly one closed fold from the
228 /// tool source (and none from the reasoning source); its identity is keyed
229 /// on the tool-call id.
230 #[test]
231 fn tool_source_emits_one_closed_fold_for_a_detailed_tool_call() {
232 // Drive the store through the wire path so `compute_folds` reads it.
233 use agent_client_protocol::schema::v1::{SessionUpdate, ToolCall as AcpToolCall};
234 use lattice_agent::SessionKey;
235 let store = ConversationStore::new(Arc::new(|_| {}));
236 let mut tc = AcpToolCall::new("tc-1", "bash");
237 tc.raw_input = Some(serde_json::json!({ "cmd": "echo hi" }));
238 tc.raw_output = Some(serde_json::json!("hi\n"));
239 store.apply(
240 &SessionKey::new("opencode", 1),
241 &SessionUpdate::ToolCall(tc),
242 );
243
244 let tool = ToolCallFoldSource::new(store.clone(), BufferId(1));
245 let folds = tool.compute_folds();
246 assert_eq!(folds.len(), 1, "one detailed tool call → one fold");
247 assert!(folds[0].closed, "folds start closed (collapsed by default)");
248 assert_eq!(folds[0].identity, Some(tool_fold_identity("tc-1")));
249 assert!(
250 folds[0].end_line > folds[0].start_line,
251 "fold spans the summary head + detail rows",
252 );
253
254 // The reasoning source sees nothing for a tool-only transcript.
255 let reasoning = ReasoningFoldSource::new(store, BufferId(1));
256 assert!(reasoning.compute_folds().is_empty());
257 }
258
259 /// A detail-less tool call is not foldable — no detail rows means a 1-line
260 /// region, which the `z*` grammar treats as a no-op.
261 #[test]
262 fn tool_source_skips_a_tool_call_without_detail() {
263 use agent_client_protocol::schema::v1::{SessionUpdate, ToolCall as AcpToolCall};
264 use lattice_agent::SessionKey;
265 let store = ConversationStore::new(Arc::new(|_| {}));
266 store.apply(
267 &SessionKey::new("opencode", 1),
268 &SessionUpdate::ToolCall(AcpToolCall::new("tc-1", "think")),
269 );
270 let tool = ToolCallFoldSource::new(store, BufferId(1));
271 assert!(tool.compute_folds().is_empty());
272 }
273
274 /// The streaming-coherence property: text growing ABOVE a tool call shifts
275 /// its fold line range but NOT its identity, so `recompute_folds`' identity
276 /// carry-over keeps an expanded call expanded. Keying identity on the line
277 /// range instead would reopen it on every streamed token.
278 #[test]
279 fn tool_fold_identity_survives_a_transcript_growing_above_it() {
280 let short = conv_with_tool_after("a");
281 let tall = conv_with_tool_after("a\nb\nc\nd");
282
283 let f_short = project_conversation(&short)
284 .1
285 .into_iter()
286 .find(|s| s.kind == ConversationFoldKind::ToolCall)
287 .expect("tool fold present");
288 let f_tall = project_conversation(&tall)
289 .1
290 .into_iter()
291 .find(|s| s.kind == ConversationFoldKind::ToolCall)
292 .expect("tool fold still present");
293
294 assert_eq!(
295 f_short.identity, f_tall.identity,
296 "identity is keyed on tool_call_id, not the line range",
297 );
298 assert_eq!(
299 f_short.identity,
300 tool_fold_identity("tc-1"),
301 "identity is the id hash"
302 );
303 // Sanity: the extra preamble lines pushed the fold down (otherwise the
304 // test proves nothing).
305 assert_eq!(
306 f_tall.start_line,
307 f_short.start_line + 3,
308 "three extra text lines shift the fold down by three",
309 );
310 }
311}