Skip to main content

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}