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//! hosts without one (Claude Code / Codex / CodeBuddy) supply a per-session
11//! `session_id` instead, which `load_active_transcript` returns as the scope id
12//! so a new session never inherits a prior one's stubs (#1004). Either way it
13//! carries a 2h TTL and we read it via
14//! [`crate::core::host_state::load_active_transcript`] but
15//! cache it behind a short TTL so the read hot path never stats+parses a file on
16//! every call. The last-known-good value is retained across a transient refresh
17//! miss, so a momentary read failure never spuriously invalidates valid stubs.
18//!
19//! A Cursor subagent (`CURSOR_TASK_ID` set) is given its own `task:{id}` scope,
20//! so it is never served — nor records — a stub under another agent's identity;
21//! this lets the stub gate replace the old blanket subagent force-fresh (#956).
22//!
23//! ## Concurrency hardening (#1040)
24//!
25//! `active_transcript.json` is a single, last-writer-wins slot, and an MCP
26//! `ctx_read` call carries no caller identity (`ToolContext` has none), so with
27//! **two concurrent top-level chats** the daemon
28//! cannot prove which chat is asking: the resolved id may be the *other* chat's
29//! (last writer) or a TTL-stale value. A matching id is therefore untrustworthy
30//! while more than one conversation is live. Rather than risk serving chat B a
31//! stub for content only chat A received, the gate **withholds every stub while
32//! more than one conversation has been active recently** — correctness over the
33//! re-read savings. Single-conversation daemons (the common case) keep the full
34//! savings; sightings are sampled on each transcript refresh. Subagents never
35//! feed this signal (they short-circuit on their `task:` scope), so a parent +
36//! its subagents are not counted as concurrent.
37//!
38//! ### Zero detection lag, and the host limit (#1042)
39//!
40//! The stub decision resolves the caller with `current_conversation_id_fresh`,
41//! which re-samples `active_transcript.json` (bypassing the `REFRESH_TTL`
42//! cache) and notes the writer *before* the gate runs. A freshly-appeared second
43//! chat is therefore detected with no lag, closing the small window in which a
44//! stub could still leak before the next refresh sampled it.
45//!
46//! What is *not* reachable as a pure lean-ctx change is recovering the stub
47//! savings while chats run concurrently: that needs a per-call caller identity,
48//! and Cursor exposes none. The MCP `tools/call` carries no conversation id (no
49//! documented `_meta`), and a `beforeMCPExecution` hook can gate a call's
50//! *permission* but not rewrite its *arguments*. So the daemon cannot prove
51//! which chat a direct `ctx_read` belongs to, and withholding under concurrency
52//! stays the correct ceiling until the host adds per-call identity.
53//!
54//! Disabled with `LEAN_CTX_CONVERSATION_SCOPE=0` (falls back to the legacy
55//! process-scoped behavior).
56
57use std::sync::OnceLock;
58use std::sync::RwLock;
59use std::sync::atomic::{AtomicBool, Ordering};
60use std::time::{Duration, Instant};
61
62/// How long a resolved conversation id stays fresh before we re-read the file.
63const REFRESH_TTL: Duration = Duration::from_secs(3);
64
65/// How long a sighting of a conversation id keeps counting toward "concurrently
66/// active". Sized to comfortably span a multi-tab session's think/act gaps so a
67/// second chat that briefly goes quiet doesn't drop below the concurrency
68/// threshold and re-open the cross-chat stub hazard (#1040).
69const CONCURRENCY_WINDOW: Duration = Duration::from_secs(30);
70
71struct Cached {
72    value: Option<String>,
73    refreshed_at: Instant,
74}
75
76fn store() -> &'static RwLock<Option<Cached>> {
77    static STORE: OnceLock<RwLock<Option<Cached>>> = OnceLock::new();
78    STORE.get_or_init(|| RwLock::new(None))
79}
80
81/// Recent sightings of distinct conversation ids (`id` → last-seen instant),
82/// fed by [`refresh`]. Drives [`multiple_conversations_recent`] so the stub gate
83/// can tell when the daemon is multiplexing more than one chat (#1040).
84fn seen_store() -> &'static RwLock<Vec<(String, Instant)>> {
85    static SEEN: OnceLock<RwLock<Vec<(String, Instant)>>> = OnceLock::new();
86    SEEN.get_or_init(|| RwLock::new(Vec::new()))
87}
88
89pub fn scope_enabled() -> bool {
90    static ENABLED: OnceLock<bool> = OnceLock::new();
91    *ENABLED.get_or_init(|| {
92        !matches!(
93            std::env::var("LEAN_CTX_CONVERSATION_SCOPE")
94                .ok()
95                .as_deref()
96                .map(str::trim),
97            Some("0" | "false" | "off")
98        )
99    })
100}
101
102/// Pure core of the Cursor `task:` scope — split out so the derivation is
103/// unit-testable without touching the process environment.
104fn subagent_scope_from(task_id: Option<&str>) -> Option<String> {
105    task_id
106        .map(str::trim)
107        .filter(|id| !id.is_empty())
108        .map(|id| format!("task:{id}"))
109}
110
111/// Full scope resolver — pure core of [`subagent_scope`], testable without
112/// touching the process environment.
113///
114/// Priority:
115/// 1. `LEAN_CTX_SCOPE` — explicit operator/integration override
116/// 2. `CURSOR_TASK_ID` — Cursor's per-subagent identifier
117/// 3. `CLAUDECODE=1` — per-process scope for Claude Code (#1292). Claude Code
118///    does not (yet) provide a per-subagent env var, so parent and sub-agent
119///    share the same `session_id`. Sub-agents also reuse the parent's MCP
120///    connection, so a `proc:` scope separates lean-ctx *processes*, not
121///    agents — [`scope_cannot_identify_caller`] therefore withholds every
122///    stub under it (#1801). `LEAN_CTX_SCOPE` is the opt-in for an
123///    integration that can name one agent per process.
124fn resolve_scope(
125    cursor_task_id: Option<&str>,
126    claudecode: Option<&str>,
127    explicit_scope: Option<&str>,
128    process_id: &str,
129) -> Option<String> {
130    if let Some(scope) = explicit_scope.map(str::trim).filter(|s| !s.is_empty()) {
131        return Some(format!("custom:{scope}"));
132    }
133    if let Some(scope) = subagent_scope_from(cursor_task_id) {
134        return Some(scope);
135    }
136    if claudecode.is_some_and(|v| v == "1") {
137        return Some(format!("proc:{process_id}"));
138    }
139    None
140}
141
142/// A unique identifier for this lean-ctx process, stable for its lifetime.
143/// Used as a scope discriminator when no explicit subagent ID is available
144/// (Claude Code, #1292).
145fn process_unique_id() -> &'static str {
146    static ID: OnceLock<String> = OnceLock::new();
147    ID.get_or_init(|| {
148        let pid = std::process::id();
149        let ts = std::time::SystemTime::now()
150            .duration_since(std::time::UNIX_EPOCH)
151            .map_or(0, |d| d.as_nanos());
152        format!("{pid}-{ts}")
153    })
154}
155
156/// A per-connection conversation scope that prevents cross-agent stub leakage.
157///
158/// Scope sources (first match wins):
159/// - `LEAN_CTX_SCOPE` — explicit override for custom integrations
160/// - `CURSOR_TASK_ID` — Cursor subagents (#952/#956)
161/// - `CLAUDECODE=1` — per-process scope for Claude Code (#1292); it keeps
162///   separate lean-ctx processes apart, but sub-agents share their parent's
163///   process, so stubs are withheld under it (#1801)
164///
165/// Returns `None` only when no agent environment is detected (standalone
166/// usage, plain Cursor without a subagent, etc.), preserving legacy
167/// transcript-based scoping.
168fn subagent_scope() -> Option<String> {
169    static SCOPE: OnceLock<Option<String>> = OnceLock::new();
170    SCOPE
171        .get_or_init(|| {
172            // #1801: the test binary must not inherit the developer's ambient
173            // agent environment. Running the suite inside Claude Code resolves a
174            // `proc:` scope for *every* test, which silently changes delivery
175            // behaviour — while the same tests pass in CI, where `CLAUDECODE` is
176            // unset. That divergence is invisible until it bites: the suite was
177            // green in CI and 31 tests failed locally purely because of an
178            // exported variable.
179            //
180            // Scope behaviour itself stays covered, through the pure cores
181            // (`resolve_scope`, `allows_stub`, `allows_cold_stub`), which take
182            // every input explicitly — the same reason the gate matrix is tested
183            // that way rather than through the process-global recency store.
184            // `test-support`: the engine as other crates' tests build it (their
185            // dev-dependency), where `cfg!(test)` of this crate is false.
186            if cfg!(any(test, feature = "test-support")) {
187                return None;
188            }
189            resolve_scope(
190                std::env::var("CURSOR_TASK_ID").ok().as_deref(),
191                std::env::var("CLAUDECODE").ok().as_deref(),
192                std::env::var("LEAN_CTX_SCOPE").ok().as_deref(),
193                process_unique_id(),
194            )
195        })
196        .clone()
197}
198
199/// The current conversation id, or `None` when no conversation context is
200/// available (hooks not installed, TTL expired with no prior value, or scoping
201/// disabled). `None` preserves the legacy process-scoped cache behavior.
202///
203/// Answers from the `REFRESH_TTL` cache when warm, so the read hot path never
204/// stats+parses a file on every call.
205pub fn current_conversation_id() -> Option<String> {
206    resolve_conversation_id(Freshness::Cached)
207}
208
209/// Like [`current_conversation_id`] but bypasses the `REFRESH_TTL` cache to
210/// re-sample `active_transcript.json` now. Used only on the re-read stub decision
211/// path: re-sampling notes a freshly-appeared second chat into the recency log
212/// (via `refresh`) *before* the gate runs, so concurrency is detected with no
213/// TTL lag and a stub can never leak to chat B in the window before detection
214/// catches up (#1042). The cost is one tiny, OS-cached transcript read per
215/// re-read — negligible against the source re-read it guards.
216pub fn current_conversation_id_fresh() -> Option<String> {
217    resolve_conversation_id(Freshness::Fresh)
218}
219
220/// Whether [`resolve_conversation_id`] may answer from the [`REFRESH_TTL`] cache
221/// ([`Freshness::Cached`]) or must re-sample the transcript ([`Freshness::Fresh`]).
222#[derive(Clone, Copy)]
223enum Freshness {
224    Cached,
225    Fresh,
226}
227
228/// Shared resolver behind [`current_conversation_id`] and its `_fresh` variant.
229/// The scope-off and subagent short-circuits are identical for both; only the
230/// `Cached` arm consults the TTL cache before falling through to [`refresh`].
231fn resolve_conversation_id(freshness: Freshness) -> Option<String> {
232    if !scope_enabled() {
233        return None;
234    }
235    // A subagent is its own scope (see `subagent_scope`) and that wins over the
236    // transcript id, so a subagent never inherits the parent's delivery identity
237    // — and never samples the transcript, so it can't feed the concurrency signal.
238    if let Some(scope) = subagent_scope() {
239        return Some(scope);
240    }
241    if let Ok(guard) = store().read()
242        && let Some(cached) = guard.as_ref()
243        && cache_is_usable(freshness, cached.refreshed_at.elapsed(), REFRESH_TTL)
244    {
245        return cached.value.clone();
246    }
247    refresh()
248}
249
250/// Pure core of the cache-vs-resample choice: a `Fresh` request always
251/// re-samples; a `Cached` one reuses an entry younger than `ttl`.
252fn cache_is_usable(freshness: Freshness, age: Duration, ttl: Duration) -> bool {
253    matches!(freshness, Freshness::Cached) && age < ttl
254}
255
256fn refresh() -> Option<String> {
257    let fresh = crate::core::host_state::load_active_transcript().and_then(|(_, conv)| conv);
258    if let Some(id) = fresh.as_deref() {
259        note_conversation_seen(id);
260    }
261    if let Ok(mut guard) = store().write() {
262        // Retain last-known-good: a transient miss (file briefly absent or
263        // expired) must not flip a stable conversation to `None` and force
264        // needless cold re-reads.
265        if fresh.is_none()
266            && let Some(existing) = guard.as_ref()
267            && existing.value.is_some()
268        {
269            let kept = existing.value.clone();
270            *guard = Some(Cached {
271                value: kept.clone(),
272                refreshed_at: Instant::now(),
273            });
274            return kept;
275        }
276        *guard = Some(Cached {
277            value: fresh.clone(),
278            refreshed_at: Instant::now(),
279        });
280    }
281    fresh
282}
283
284/// Record a sighting of `id` in the recency log. Thin wrapper over the pure
285/// [`note_into`] so the global store stays an implementation detail.
286fn note_conversation_seen(id: &str) {
287    if let Ok(mut v) = seen_store().write() {
288        note_into(&mut v, id, Instant::now(), CONCURRENCY_WINDOW);
289    }
290}
291
292/// Pure core of [`note_conversation_seen`]: upsert `id`'s last-seen timestamp and
293/// drop sightings older than `window`. One entry per id, so the vec length is the
294/// number of distinct recent conversations.
295fn note_into(v: &mut Vec<(String, Instant)>, id: &str, now: Instant, window: Duration) {
296    v.retain(|(_, t)| now.duration_since(*t) < window);
297    if let Some(entry) = v.iter_mut().find(|(seen, _)| seen == id) {
298        entry.1 = now;
299    } else {
300        v.push((id.to_string(), now));
301    }
302}
303
304/// Pure core of [`multiple_conversations_recent`]: distinct ids seen within
305/// `window` of `now`.
306fn distinct_within(v: &[(String, Instant)], now: Instant, window: Duration) -> usize {
307    v.iter()
308        .filter(|(_, t)| now.duration_since(*t) < window)
309        .count()
310}
311
312/// True when more than one conversation has been active within
313/// [`CONCURRENCY_WINDOW`] — i.e. the daemon is multiplexing chats, so the shared
314/// `active_transcript.json` id can't be trusted to name the current caller and no
315/// stub is provably in-context (#1040).
316pub(crate) fn multiple_conversations_recent() -> bool {
317    seen_store()
318        .read()
319        .is_ok_and(|v| distinct_within(&v, Instant::now(), CONCURRENCY_WINDOW) > 1)
320}
321
322/// Whether the active scope can name an *individual* caller.
323///
324/// #1801: the `proc:` scope was introduced for Claude Code (#1292) on the
325/// premise that "each MCP connection is a separate stdio process", so one
326/// process meant one consumer. That premise does not hold — Claude Code
327/// sub-agents reuse their parent's MCP connection and spawn no lean-ctx
328/// process of their own, so the parent and every sub-agent resolve the
329/// *identical* `proc:` id. A match therefore proves nothing about who is
330/// asking, and a stub served on it reaches a sub-agent whose context window
331/// never held the content.
332///
333/// This is the same hazard `multiple_conversations_recent` already fails
334/// closed on, so it gets the same treatment. `task:` (Cursor) and `custom:`
335/// (explicit override) do name one agent and stay unaffected; so does the
336/// legacy transcript path, where one daemon serves one conversation.
337pub(crate) fn scope_cannot_identify_caller() -> bool {
338    subagent_scope().is_some_and(|scope| scope.starts_with("proc:"))
339}
340
341/// Set once by a one-shot CLI process (`lean-ctx call`, #2060).
342static ONE_SHOT_PROCESS: AtomicBool = AtomicBool::new(false);
343
344/// Marks this process as one-shot: its output goes to a caller whose context
345/// holds nothing this process delivered, so no stub or cross-agent reference
346/// can be in context (#2060). Such a process still resolves the shared
347/// `active_transcript.json` conversation id, which made earlier deliveries
348/// look like its own.
349pub fn mark_one_shot_process() {
350    ONE_SHOT_PROCESS.store(true, Ordering::Relaxed);
351}
352
353/// Whether [`mark_one_shot_process`] ran in this process.
354pub fn is_one_shot_process() -> bool {
355    ONE_SHOT_PROCESS.load(Ordering::Relaxed)
356}
357
358/// Whether a `[unchanged]` stub may be served for an entry that was delivered to
359/// `delivered`, given the `current` conversation.
360///
361/// `current == None` (no conversation context) preserves the legacy
362/// process-scoped behavior — stub allowed — **unless** the caller cannot be
363/// identified: more than one conversation has been active recently (#1040), or
364/// the scope is only process-derived (#1801). Then every stub is withheld.
365///
366/// A one-shot process never serves a stub (#2060), in legacy mode included.
367pub fn conversation_allows_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
368    !ONE_SHOT_PROCESS.load(Ordering::Relaxed)
369        && allows_stub(
370            scope_enabled(),
371            multiple_conversations_recent() || scope_cannot_identify_caller(),
372            current,
373            delivered,
374        )
375}
376
377/// Pure decision core (no env / global reads) so the full matrix is unit-testable.
378///
379/// `unprovable` folds together every reason a matching id fails to prove the
380/// content is in *this* caller's context: several live chats (#1040) and a
381/// scope that cannot name one caller (#1801).
382fn allows_stub(
383    scope_on: bool,
384    unprovable: bool,
385    current: Option<&str>,
386    delivered: Option<&str>,
387) -> bool {
388    if !scope_on {
389        // Explicit legacy mode: one daemon == one conversation by contract.
390        return true;
391    }
392    if unprovable {
393        // A matching id can't be trusted to name THIS caller, so nothing is
394        // provably in-context — withhold every stub (#1040, #1801).
395        return false;
396    }
397    match (current, delivered) {
398        (Some(c), Some(d)) => c == d,
399        // Known caller, unknown delivery (pre-scoping entry) → can't prove → block.
400        (Some(_), None) => false,
401        // Unknown caller on a single-conversation daemon → legacy allow.
402        (None, _) => true,
403    }
404}
405
406/// Whether a *cold* `[unchanged]` stub may be served — i.e. one backed only by
407/// the persisted index ([`crate::core::read_stub_index`]) after a daemon
408/// restart, with no live in-memory entry.
409///
410/// Stricter than [`conversation_allows_stub`]: a cold stub crosses a process
411/// boundary, so we serve it **only** when both sides name the *same, known*
412/// conversation. Unlike the warm path there is no "no context → legacy" escape,
413/// because without a current conversation id we cannot prove the content is in
414/// the new process's context, and a wrong cold stub would resurrect exactly the
415/// cross-chat hazard #954 closed. Also withheld under concurrency (#1040).
416pub fn conversation_allows_cold_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
417    allows_cold_stub(
418        multiple_conversations_recent() || scope_cannot_identify_caller(),
419        current,
420        delivered,
421    )
422}
423
424/// Pure decision core of [`conversation_allows_cold_stub`]. `unprovable` carries
425/// the same meaning as in [`allows_stub`].
426fn allows_cold_stub(unprovable: bool, current: Option<&str>, delivered: Option<&str>) -> bool {
427    !unprovable && matches!((current, delivered), (Some(c), Some(d)) if c == d)
428}
429
430/// Detects a subagent (forked agent) execution context.
431///
432/// A subagent must never be served a stub for content only the parent received.
433/// That used to be enforced by force-freshing *every* subagent read; with
434/// conversation scoping (#954/#955) the subagent instead runs under its own scope
435/// ([`current_conversation_id`] → `task:{id}` or `proc:{id}`), so
436/// the stub gate withholds cross-agent stubs precisely while restoring the
437/// subagent's *own* cheap re-reads. The blanket force-fresh is therefore kept
438/// only as the fallback when scoping is disabled (#956).
439///
440/// Checks `CURSOR_TASK_ID` (Cursor) and `CLAUDE_CODE_ENTRYPOINT=local-agent`
441/// (future Claude Code subagent marker). Current Claude Code is handled by
442/// per-process scoping in this module (#1292).
443pub fn is_subagent_context() -> bool {
444    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
445    *IS_SUBAGENT.get_or_init(|| {
446        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
447            || std::env::var("CLAUDE_CODE_ENTRYPOINT")
448                .ok()
449                .as_deref()
450                .map(str::trim)
451                == Some("local-agent")
452    })
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    // The gate matrix is tested through the pure cores (`allows_stub` /
460    // `allows_cold_stub`) so the assertions are deterministic regardless of the
461    // process-global recency store, which other tests mutate in parallel.
462
463    /// #1801: a `proc:` scope is shared by the parent and every Claude Code
464    /// sub-agent, so identical ids prove nothing about who is asking. The gate
465    /// must withhold exactly as it does for concurrent chats — otherwise a
466    /// sub-agent receives a reference to content it has never seen.
467    #[test]
468    fn process_derived_scope_withholds_stubs_even_when_ids_match() {
469        let scope = "proc:4242-1700000000";
470        assert!(
471            !allows_stub(true, true, Some(scope), Some(scope)),
472            "a shared process scope must not authorize a stub"
473        );
474        assert!(
475            !allows_cold_stub(true, Some(scope), Some(scope)),
476            "the cold path must withhold for the same reason"
477        );
478    }
479
480    /// A scope that *does* name one agent keeps its savings: Cursor's `task:`
481    /// and an explicit `custom:` override are per-agent by construction.
482    #[test]
483    fn per_agent_scopes_still_allow_stubs() {
484        for scope in ["task:abc123", "custom:my-worker"] {
485            assert!(
486                allows_stub(true, false, Some(scope), Some(scope)),
487                "{scope} identifies one caller and must keep deduplicating"
488            );
489        }
490    }
491
492    #[test]
493    fn only_process_scopes_are_treated_as_unidentifiable() {
494        assert!(subagent_scope_from(Some("abc")).is_some_and(|s| !s.starts_with("proc:")));
495        assert!(
496            resolve_scope(None, Some("1"), None, "4242-1700000000")
497                .is_some_and(|s| s.starts_with("proc:")),
498            "Claude Code without a per-subagent id must resolve to a proc: scope"
499        );
500    }
501
502    #[test]
503    fn no_current_context_allows_stub_legacy() {
504        assert!(allows_stub(true, false, None, None));
505        assert!(allows_stub(true, false, None, Some("conv-a")));
506    }
507
508    #[test]
509    fn scope_disabled_always_allows_stub() {
510        // Explicit opt-out → legacy process scope, even under (irrelevant) concurrency.
511        assert!(allows_stub(false, true, Some("conv-b"), Some("conv-a")));
512        assert!(allows_stub(false, true, None, None));
513    }
514
515    #[test]
516    fn same_conversation_allows_stub() {
517        assert!(allows_stub(true, false, Some("conv-a"), Some("conv-a")));
518    }
519
520    #[test]
521    fn different_conversation_blocks_stub() {
522        assert!(!allows_stub(true, false, Some("conv-b"), Some("conv-a")));
523    }
524
525    #[test]
526    fn unknown_delivering_conversation_blocks_stub() {
527        // Entry delivered before scoping existed → cannot prove it is in context.
528        assert!(!allows_stub(true, false, Some("conv-a"), None));
529    }
530
531    #[test]
532    fn concurrency_withholds_every_warm_stub() {
533        // #1040: while >1 chat is live, even a same-id match is untrustworthy.
534        assert!(!allows_stub(true, true, Some("conv-a"), Some("conv-a")));
535        assert!(!allows_stub(true, true, None, None));
536        assert!(!allows_stub(true, true, None, Some("conv-a")));
537    }
538
539    #[test]
540    fn cold_stub_requires_both_known_and_matching() {
541        assert!(allows_cold_stub(false, Some("c"), Some("c")));
542        assert!(!allows_cold_stub(false, Some("c"), Some("d")));
543        // No "legacy" escape for the cold path: unknown either side → blocked.
544        assert!(!allows_cold_stub(false, None, Some("c")));
545        assert!(!allows_cold_stub(false, Some("c"), None));
546        assert!(!allows_cold_stub(false, None, None));
547    }
548
549    #[test]
550    fn cold_stub_withheld_under_concurrency() {
551        // #1040: a matching cold stub is still withheld while chats are multiplexed.
552        assert!(!allows_cold_stub(true, Some("c"), Some("c")));
553    }
554
555    #[test]
556    fn fresh_request_resamples_cached_request_honors_ttl() {
557        let ttl = Duration::from_secs(3);
558        // `Fresh` ignores the cache even for a brand-new entry → always re-samples,
559        // so the stub gate sees a just-appeared second chat with zero lag (#1042).
560        assert!(!cache_is_usable(
561            Freshness::Fresh,
562            Duration::from_millis(0),
563            ttl
564        ));
565        assert!(!cache_is_usable(Freshness::Fresh, ttl * 100, ttl));
566        // `Cached` reuses a within-TTL entry but re-samples once it has expired
567        // (age == ttl is already expired: the window is a strict `<`).
568        assert!(cache_is_usable(
569            Freshness::Cached,
570            Duration::from_secs(1),
571            ttl
572        ));
573        assert!(!cache_is_usable(Freshness::Cached, ttl, ttl));
574        assert!(!cache_is_usable(Freshness::Cached, ttl * 2, ttl));
575    }
576
577    #[test]
578    fn recency_counts_distinct_ids_and_dedupes_repeats() {
579        let now = Instant::now();
580        let win = Duration::from_secs(30);
581        let mut v = Vec::new();
582        note_into(&mut v, "a", now, win);
583        assert_eq!(distinct_within(&v, now, win), 1);
584        note_into(&mut v, "a", now, win); // same id refreshes, no new entry
585        assert_eq!(distinct_within(&v, now, win), 1);
586        note_into(&mut v, "b", now, win); // second chat → concurrency
587        assert_eq!(distinct_within(&v, now, win), 2);
588    }
589
590    #[test]
591    fn recency_prunes_sightings_older_than_window() {
592        let win = Duration::from_secs(30);
593        let t0 = Instant::now();
594        let mut v = Vec::new();
595        note_into(&mut v, "old", t0, win);
596        // A sighting well past the window prunes the stale one — back to one chat.
597        let t1 = t0 + win * 2;
598        note_into(&mut v, "new", t1, win);
599        assert_eq!(distinct_within(&v, t1, win), 1);
600    }
601
602    #[test]
603    fn subagent_scope_derives_a_distinct_non_none_id_from_task() {
604        assert_eq!(
605            subagent_scope_from(Some("abc123")),
606            Some("task:abc123".to_string())
607        );
608        assert_eq!(
609            subagent_scope_from(Some("  abc  ")),
610            Some("task:abc".to_string())
611        );
612        assert_eq!(subagent_scope_from(Some("")), None);
613        assert_eq!(subagent_scope_from(Some("   ")), None);
614        assert_eq!(subagent_scope_from(None), None);
615    }
616
617    #[test]
618    fn subagent_scope_never_matches_a_plain_conversation() {
619        let sub = subagent_scope_from(Some("xyz")).unwrap();
620        assert!(!allows_stub(true, false, Some(&sub), Some("xyz")));
621        assert!(allows_stub(true, false, Some(&sub), Some(&sub)));
622    }
623
624    // --- resolve_scope (multi-host) tests (#1292) ---
625
626    #[test]
627    fn resolve_scope_explicit_override_wins() {
628        let s = resolve_scope(Some("task-1"), Some("1"), Some("my-scope"), "42-99");
629        assert_eq!(s, Some("custom:my-scope".to_string()));
630    }
631
632    #[test]
633    fn resolve_scope_cursor_task_id_beats_claude_code() {
634        let s = resolve_scope(Some("task-1"), Some("1"), None, "42-99");
635        assert_eq!(s, Some("task:task-1".to_string()));
636    }
637
638    #[test]
639    fn resolve_scope_claude_code_gets_process_scope() {
640        let s = resolve_scope(None, Some("1"), None, "100-111");
641        assert_eq!(s, Some("proc:100-111".to_string()));
642    }
643
644    #[test]
645    fn resolve_scope_no_agent_env_returns_none() {
646        assert_eq!(resolve_scope(None, None, None, "42-99"), None);
647    }
648
649    #[test]
650    fn resolve_scope_claude_code_different_processes_differ() {
651        let s1 = resolve_scope(None, Some("1"), None, "100-111").unwrap();
652        let s2 = resolve_scope(None, Some("1"), None, "200-222").unwrap();
653        assert_ne!(
654            s1, s2,
655            "different lean-ctx processes must get different scopes"
656        );
657    }
658
659    #[test]
660    fn claude_code_process_scope_never_matches_session_id() {
661        let scope = resolve_scope(None, Some("1"), None, "42-12345").unwrap();
662        assert!(!allows_stub(true, false, Some(&scope), Some("sess-abc")));
663        assert!(allows_stub(true, false, Some(&scope), Some(&scope)));
664    }
665
666    #[test]
667    fn claude_code_parent_and_subagent_stubs_isolated() {
668        let parent = resolve_scope(None, Some("1"), None, "100-111").unwrap();
669        let child = resolve_scope(None, Some("1"), None, "200-222").unwrap();
670        assert!(allows_stub(true, false, Some(&parent), Some(&parent)));
671        assert!(!allows_stub(true, false, Some(&child), Some(&parent)));
672        assert!(!allows_cold_stub(false, Some(&child), Some(&parent)));
673    }
674
675    #[test]
676    fn resolve_scope_blank_explicit_override_ignored() {
677        assert_eq!(resolve_scope(None, None, Some("  "), "42-99"), None);
678        assert_eq!(resolve_scope(None, None, Some(""), "42-99"), None);
679    }
680}