lean_ctx/core/conversation.rs
1//! Conversation identity for read-cache scoping.
2//!
3//! The `[unchanged]` re-read stub means *"you already have this in context"* —
4//! which is only true within the **same conversation / context window**. The
5//! read [`SessionCache`](crate::core::cache::SessionCache) is shared across all
6//! chats served by one daemon, so without scoping a file delivered in chat A
7//! could be stubbed for a re-read in chat B (which never received it).
8//!
9//! Cursor's hooks write the live conversation id to `active_transcript.json`
10//! (2h TTL); we read it via [`crate::hook_handlers::load_active_transcript`] but
11//! cache it behind a short TTL so the read hot path never stats+parses a file on
12//! every call. The last-known-good value is retained across a transient refresh
13//! miss, so a momentary read failure never spuriously invalidates valid stubs.
14//!
15//! A Cursor subagent (`CURSOR_TASK_ID` set) is given its own `task:{id}` scope,
16//! so it is never served — nor records — a stub under another agent's identity;
17//! this lets the stub gate replace the old blanket subagent force-fresh (#956).
18//!
19//! ## Concurrency hardening (#1040)
20//!
21//! `active_transcript.json` is a single, last-writer-wins slot, and an MCP
22//! `ctx_read` call carries no caller identity (`ToolContext` has none), so with
23//! **two concurrent top-level chats** the daemon
24//! cannot prove which chat is asking: the resolved id may be the *other* chat's
25//! (last writer) or a TTL-stale value. A matching id is therefore untrustworthy
26//! while more than one conversation is live. Rather than risk serving chat B a
27//! stub for content only chat A received, the gate **withholds every stub while
28//! more than one conversation has been active recently** — correctness over the
29//! re-read savings. Single-conversation daemons (the common case) keep the full
30//! savings; sightings are sampled on each transcript refresh. Subagents never
31//! feed this signal (they short-circuit on their `task:` scope), so a parent +
32//! its subagents are not counted as concurrent.
33//!
34//! ### Zero detection lag, and the host limit (#1042)
35//!
36//! The stub decision resolves the caller with [`current_conversation_id_fresh`],
37//! which re-samples `active_transcript.json` (bypassing the `REFRESH_TTL`
38//! cache) and notes the writer *before* the gate runs. A freshly-appeared second
39//! chat is therefore detected with no lag, closing the small window in which a
40//! stub could still leak before the next refresh sampled it.
41//!
42//! What is *not* reachable as a pure lean-ctx change is recovering the stub
43//! savings while chats run concurrently: that needs a per-call caller identity,
44//! and Cursor exposes none. The MCP `tools/call` carries no conversation id (no
45//! documented `_meta`), and a `beforeMCPExecution` hook can gate a call's
46//! *permission* but not rewrite its *arguments*. So the daemon cannot prove
47//! which chat a direct `ctx_read` belongs to, and withholding under concurrency
48//! stays the correct ceiling until the host adds per-call identity.
49//!
50//! Disabled with `LEAN_CTX_CONVERSATION_SCOPE=0` (falls back to the legacy
51//! process-scoped behavior).
52
53use std::sync::OnceLock;
54use std::sync::RwLock;
55use std::time::{Duration, Instant};
56
57/// How long a resolved conversation id stays fresh before we re-read the file.
58const REFRESH_TTL: Duration = Duration::from_secs(3);
59
60/// How long a sighting of a conversation id keeps counting toward "concurrently
61/// active". Sized to comfortably span a multi-tab session's think/act gaps so a
62/// second chat that briefly goes quiet doesn't drop below the concurrency
63/// threshold and re-open the cross-chat stub hazard (#1040).
64const CONCURRENCY_WINDOW: Duration = Duration::from_secs(30);
65
66struct Cached {
67 value: Option<String>,
68 refreshed_at: Instant,
69}
70
71fn store() -> &'static RwLock<Option<Cached>> {
72 static STORE: OnceLock<RwLock<Option<Cached>>> = OnceLock::new();
73 STORE.get_or_init(|| RwLock::new(None))
74}
75
76/// Recent sightings of distinct conversation ids (`id` → last-seen instant),
77/// fed by [`refresh`]. Drives [`multiple_conversations_recent`] so the stub gate
78/// can tell when the daemon is multiplexing more than one chat (#1040).
79fn seen_store() -> &'static RwLock<Vec<(String, Instant)>> {
80 static SEEN: OnceLock<RwLock<Vec<(String, Instant)>>> = OnceLock::new();
81 SEEN.get_or_init(|| RwLock::new(Vec::new()))
82}
83
84pub(crate) fn scope_enabled() -> bool {
85 static ENABLED: OnceLock<bool> = OnceLock::new();
86 *ENABLED.get_or_init(|| {
87 !matches!(
88 std::env::var("LEAN_CTX_CONVERSATION_SCOPE")
89 .ok()
90 .as_deref()
91 .map(str::trim),
92 Some("0" | "false" | "off")
93 )
94 })
95}
96
97/// Pure core of [`subagent_scope`] — split out so the derivation is unit-testable
98/// without touching the process environment.
99fn subagent_scope_from(task_id: Option<&str>) -> Option<String> {
100 task_id
101 .map(str::trim)
102 .filter(|id| !id.is_empty())
103 .map(|id| format!("task:{id}"))
104}
105
106/// A subagent's dedicated conversation scope, derived from `CURSOR_TASK_ID`.
107///
108/// A Cursor subagent (Task) runs with `CURSOR_TASK_ID` set for its whole life.
109/// Giving it a distinct, non-`None` scope means it can never match the parent's
110/// (or a sibling's) `delivered_conversation`, so it is never served a stub for
111/// content only another agent received — while its *own* re-reads still collapse
112/// to the cheap stub. This replaces the old blanket force-fresh for subagents
113/// (#952/#956).
114fn subagent_scope() -> Option<String> {
115 static SCOPE: OnceLock<Option<String>> = OnceLock::new();
116 SCOPE
117 .get_or_init(|| subagent_scope_from(std::env::var("CURSOR_TASK_ID").ok().as_deref()))
118 .clone()
119}
120
121/// The current conversation id, or `None` when no conversation context is
122/// available (hooks not installed, TTL expired with no prior value, or scoping
123/// disabled). `None` preserves the legacy process-scoped cache behavior.
124///
125/// Answers from the `REFRESH_TTL` cache when warm, so the read hot path never
126/// stats+parses a file on every call.
127pub fn current_conversation_id() -> Option<String> {
128 resolve_conversation_id(Freshness::Cached)
129}
130
131/// Like [`current_conversation_id`] but bypasses the `REFRESH_TTL` cache to
132/// re-sample `active_transcript.json` now. Used only on the re-read stub decision
133/// path: re-sampling notes a freshly-appeared second chat into the recency log
134/// (via `refresh`) *before* the gate runs, so concurrency is detected with no
135/// TTL lag and a stub can never leak to chat B in the window before detection
136/// catches up (#1042). The cost is one tiny, OS-cached transcript read per
137/// re-read — negligible against the source re-read it guards.
138pub fn current_conversation_id_fresh() -> Option<String> {
139 resolve_conversation_id(Freshness::Fresh)
140}
141
142/// Whether [`resolve_conversation_id`] may answer from the [`REFRESH_TTL`] cache
143/// ([`Freshness::Cached`]) or must re-sample the transcript ([`Freshness::Fresh`]).
144#[derive(Clone, Copy)]
145enum Freshness {
146 Cached,
147 Fresh,
148}
149
150/// Shared resolver behind [`current_conversation_id`] and its `_fresh` variant.
151/// The scope-off and subagent short-circuits are identical for both; only the
152/// `Cached` arm consults the TTL cache before falling through to [`refresh`].
153fn resolve_conversation_id(freshness: Freshness) -> Option<String> {
154 if !scope_enabled() {
155 return None;
156 }
157 // A subagent is its own scope (see `subagent_scope`) and that wins over the
158 // transcript id, so a subagent never inherits the parent's delivery identity
159 // — and never samples the transcript, so it can't feed the concurrency signal.
160 if let Some(scope) = subagent_scope() {
161 return Some(scope);
162 }
163 if let Ok(guard) = store().read()
164 && let Some(cached) = guard.as_ref()
165 && cache_is_usable(freshness, cached.refreshed_at.elapsed(), REFRESH_TTL)
166 {
167 return cached.value.clone();
168 }
169 refresh()
170}
171
172/// Pure core of the cache-vs-resample choice: a `Fresh` request always
173/// re-samples; a `Cached` one reuses an entry younger than `ttl`.
174fn cache_is_usable(freshness: Freshness, age: Duration, ttl: Duration) -> bool {
175 matches!(freshness, Freshness::Cached) && age < ttl
176}
177
178fn refresh() -> Option<String> {
179 let fresh = crate::hook_handlers::load_active_transcript().and_then(|(_, conv)| conv);
180 if let Some(id) = fresh.as_deref() {
181 note_conversation_seen(id);
182 }
183 if let Ok(mut guard) = store().write() {
184 // Retain last-known-good: a transient miss (file briefly absent or
185 // expired) must not flip a stable conversation to `None` and force
186 // needless cold re-reads.
187 if fresh.is_none()
188 && let Some(existing) = guard.as_ref()
189 && existing.value.is_some()
190 {
191 let kept = existing.value.clone();
192 *guard = Some(Cached {
193 value: kept.clone(),
194 refreshed_at: Instant::now(),
195 });
196 return kept;
197 }
198 *guard = Some(Cached {
199 value: fresh.clone(),
200 refreshed_at: Instant::now(),
201 });
202 }
203 fresh
204}
205
206/// Record a sighting of `id` in the recency log. Thin wrapper over the pure
207/// [`note_into`] so the global store stays an implementation detail.
208fn note_conversation_seen(id: &str) {
209 if let Ok(mut v) = seen_store().write() {
210 note_into(&mut v, id, Instant::now(), CONCURRENCY_WINDOW);
211 }
212}
213
214/// Pure core of [`note_conversation_seen`]: upsert `id`'s last-seen timestamp and
215/// drop sightings older than `window`. One entry per id, so the vec length is the
216/// number of distinct recent conversations.
217fn note_into(v: &mut Vec<(String, Instant)>, id: &str, now: Instant, window: Duration) {
218 v.retain(|(_, t)| now.duration_since(*t) < window);
219 if let Some(entry) = v.iter_mut().find(|(seen, _)| seen == id) {
220 entry.1 = now;
221 } else {
222 v.push((id.to_string(), now));
223 }
224}
225
226/// Pure core of [`multiple_conversations_recent`]: distinct ids seen within
227/// `window` of `now`.
228fn distinct_within(v: &[(String, Instant)], now: Instant, window: Duration) -> usize {
229 v.iter()
230 .filter(|(_, t)| now.duration_since(*t) < window)
231 .count()
232}
233
234/// True when more than one conversation has been active within
235/// [`CONCURRENCY_WINDOW`] — i.e. the daemon is multiplexing chats, so the shared
236/// `active_transcript.json` id can't be trusted to name the current caller and no
237/// stub is provably in-context (#1040).
238pub(crate) fn multiple_conversations_recent() -> bool {
239 seen_store()
240 .read()
241 .is_ok_and(|v| distinct_within(&v, Instant::now(), CONCURRENCY_WINDOW) > 1)
242}
243
244/// Whether a `[unchanged]` stub may be served for an entry that was delivered to
245/// `delivered`, given the `current` conversation.
246///
247/// `current == None` (no conversation context) preserves the legacy
248/// process-scoped behavior — stub allowed — **unless** more than one conversation
249/// has been active recently, in which case every stub is withheld because the
250/// shared id signal can't identify the caller (#1040).
251pub fn conversation_allows_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
252 allows_stub(
253 scope_enabled(),
254 multiple_conversations_recent(),
255 current,
256 delivered,
257 )
258}
259
260/// Pure decision core (no env / global reads) so the full matrix is unit-testable.
261fn allows_stub(
262 scope_on: bool,
263 concurrent: bool,
264 current: Option<&str>,
265 delivered: Option<&str>,
266) -> bool {
267 if !scope_on {
268 // Explicit legacy mode: one daemon == one conversation by contract.
269 return true;
270 }
271 if concurrent {
272 // Multiple chats live: a matching id can't be trusted to name THIS caller,
273 // so nothing is provably in-context — withhold every stub (#1040).
274 return false;
275 }
276 match (current, delivered) {
277 (Some(c), Some(d)) => c == d,
278 // Known caller, unknown delivery (pre-scoping entry) → can't prove → block.
279 (Some(_), None) => false,
280 // Unknown caller on a single-conversation daemon → legacy allow.
281 (None, _) => true,
282 }
283}
284
285/// Whether a *cold* `[unchanged]` stub may be served — i.e. one backed only by
286/// the persisted index ([`crate::core::read_stub_index`]) after a daemon
287/// restart, with no live in-memory entry.
288///
289/// Stricter than [`conversation_allows_stub`]: a cold stub crosses a process
290/// boundary, so we serve it **only** when both sides name the *same, known*
291/// conversation. Unlike the warm path there is no "no context → legacy" escape,
292/// because without a current conversation id we cannot prove the content is in
293/// the new process's context, and a wrong cold stub would resurrect exactly the
294/// cross-chat hazard #954 closed. Also withheld under concurrency (#1040).
295pub fn conversation_allows_cold_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
296 allows_cold_stub(multiple_conversations_recent(), current, delivered)
297}
298
299/// Pure decision core of [`conversation_allows_cold_stub`].
300fn allows_cold_stub(concurrent: bool, current: Option<&str>, delivered: Option<&str>) -> bool {
301 !concurrent && matches!((current, delivered), (Some(c), Some(d)) if c == d)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 // The gate matrix is tested through the pure cores (`allows_stub` /
309 // `allows_cold_stub`) so the assertions are deterministic regardless of the
310 // process-global recency store, which other tests mutate in parallel.
311
312 #[test]
313 fn no_current_context_allows_stub_legacy() {
314 // Single-conversation daemon, scoping on: behave exactly as before.
315 assert!(allows_stub(true, false, None, None));
316 assert!(allows_stub(true, false, None, Some("conv-a")));
317 }
318
319 #[test]
320 fn scope_disabled_always_allows_stub() {
321 // Explicit opt-out → legacy process scope, even under (irrelevant) concurrency.
322 assert!(allows_stub(false, true, Some("conv-b"), Some("conv-a")));
323 assert!(allows_stub(false, true, None, None));
324 }
325
326 #[test]
327 fn same_conversation_allows_stub() {
328 assert!(allows_stub(true, false, Some("conv-a"), Some("conv-a")));
329 }
330
331 #[test]
332 fn different_conversation_blocks_stub() {
333 assert!(!allows_stub(true, false, Some("conv-b"), Some("conv-a")));
334 }
335
336 #[test]
337 fn unknown_delivering_conversation_blocks_stub() {
338 // Entry delivered before scoping existed → cannot prove it is in context.
339 assert!(!allows_stub(true, false, Some("conv-a"), None));
340 }
341
342 #[test]
343 fn concurrency_withholds_every_warm_stub() {
344 // #1040: while >1 chat is live, even a same-id match is untrustworthy.
345 assert!(!allows_stub(true, true, Some("conv-a"), Some("conv-a")));
346 assert!(!allows_stub(true, true, None, None));
347 assert!(!allows_stub(true, true, None, Some("conv-a")));
348 }
349
350 #[test]
351 fn cold_stub_requires_both_known_and_matching() {
352 assert!(allows_cold_stub(false, Some("c"), Some("c")));
353 assert!(!allows_cold_stub(false, Some("c"), Some("d")));
354 // No "legacy" escape for the cold path: unknown either side → blocked.
355 assert!(!allows_cold_stub(false, None, Some("c")));
356 assert!(!allows_cold_stub(false, Some("c"), None));
357 assert!(!allows_cold_stub(false, None, None));
358 }
359
360 #[test]
361 fn cold_stub_withheld_under_concurrency() {
362 // #1040: a matching cold stub is still withheld while chats are multiplexed.
363 assert!(!allows_cold_stub(true, Some("c"), Some("c")));
364 }
365
366 #[test]
367 fn fresh_request_resamples_cached_request_honors_ttl() {
368 let ttl = Duration::from_secs(3);
369 // `Fresh` ignores the cache even for a brand-new entry → always re-samples,
370 // so the stub gate sees a just-appeared second chat with zero lag (#1042).
371 assert!(!cache_is_usable(
372 Freshness::Fresh,
373 Duration::from_millis(0),
374 ttl
375 ));
376 assert!(!cache_is_usable(Freshness::Fresh, ttl * 100, ttl));
377 // `Cached` reuses a within-TTL entry but re-samples once it has expired
378 // (age == ttl is already expired: the window is a strict `<`).
379 assert!(cache_is_usable(
380 Freshness::Cached,
381 Duration::from_secs(1),
382 ttl
383 ));
384 assert!(!cache_is_usable(Freshness::Cached, ttl, ttl));
385 assert!(!cache_is_usable(Freshness::Cached, ttl * 2, ttl));
386 }
387
388 #[test]
389 fn recency_counts_distinct_ids_and_dedupes_repeats() {
390 let now = Instant::now();
391 let win = Duration::from_secs(30);
392 let mut v = Vec::new();
393 note_into(&mut v, "a", now, win);
394 assert_eq!(distinct_within(&v, now, win), 1);
395 note_into(&mut v, "a", now, win); // same id refreshes, no new entry
396 assert_eq!(distinct_within(&v, now, win), 1);
397 note_into(&mut v, "b", now, win); // second chat → concurrency
398 assert_eq!(distinct_within(&v, now, win), 2);
399 }
400
401 #[test]
402 fn recency_prunes_sightings_older_than_window() {
403 let win = Duration::from_secs(30);
404 let t0 = Instant::now();
405 let mut v = Vec::new();
406 note_into(&mut v, "old", t0, win);
407 // A sighting well past the window prunes the stale one — back to one chat.
408 let t1 = t0 + win * 2;
409 note_into(&mut v, "new", t1, win);
410 assert_eq!(distinct_within(&v, t1, win), 1);
411 }
412
413 #[test]
414 fn subagent_scope_derives_a_distinct_non_none_id_from_task() {
415 assert_eq!(
416 subagent_scope_from(Some("abc123")),
417 Some("task:abc123".to_string())
418 );
419 // Whitespace-padded ids are trimmed; empty/blank/absent → no scope.
420 assert_eq!(
421 subagent_scope_from(Some(" abc ")),
422 Some("task:abc".to_string())
423 );
424 assert_eq!(subagent_scope_from(Some("")), None);
425 assert_eq!(subagent_scope_from(Some(" ")), None);
426 assert_eq!(subagent_scope_from(None), None);
427 }
428
429 #[test]
430 fn subagent_scope_never_matches_a_plain_conversation() {
431 // The `task:` prefix guarantees a subagent can't collide with a parent's
432 // transcript conversation id, so the stub gate always withholds the
433 // parent's delivery from the subagent.
434 let sub = subagent_scope_from(Some("xyz")).unwrap();
435 assert!(!allows_stub(true, false, Some(&sub), Some("xyz")));
436 assert!(allows_stub(true, false, Some(&sub), Some(&sub)));
437 }
438}