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 if cfg!(test) {
185 return None;
186 }
187 resolve_scope(
188 std::env::var("CURSOR_TASK_ID").ok().as_deref(),
189 std::env::var("CLAUDECODE").ok().as_deref(),
190 std::env::var("LEAN_CTX_SCOPE").ok().as_deref(),
191 process_unique_id(),
192 )
193 })
194 .clone()
195}
196
197/// The current conversation id, or `None` when no conversation context is
198/// available (hooks not installed, TTL expired with no prior value, or scoping
199/// disabled). `None` preserves the legacy process-scoped cache behavior.
200///
201/// Answers from the `REFRESH_TTL` cache when warm, so the read hot path never
202/// stats+parses a file on every call.
203pub fn current_conversation_id() -> Option<String> {
204 resolve_conversation_id(Freshness::Cached)
205}
206
207/// Like [`current_conversation_id`] but bypasses the `REFRESH_TTL` cache to
208/// re-sample `active_transcript.json` now. Used only on the re-read stub decision
209/// path: re-sampling notes a freshly-appeared second chat into the recency log
210/// (via `refresh`) *before* the gate runs, so concurrency is detected with no
211/// TTL lag and a stub can never leak to chat B in the window before detection
212/// catches up (#1042). The cost is one tiny, OS-cached transcript read per
213/// re-read — negligible against the source re-read it guards.
214pub fn current_conversation_id_fresh() -> Option<String> {
215 resolve_conversation_id(Freshness::Fresh)
216}
217
218/// Whether [`resolve_conversation_id`] may answer from the [`REFRESH_TTL`] cache
219/// ([`Freshness::Cached`]) or must re-sample the transcript ([`Freshness::Fresh`]).
220#[derive(Clone, Copy)]
221enum Freshness {
222 Cached,
223 Fresh,
224}
225
226/// Shared resolver behind [`current_conversation_id`] and its `_fresh` variant.
227/// The scope-off and subagent short-circuits are identical for both; only the
228/// `Cached` arm consults the TTL cache before falling through to [`refresh`].
229fn resolve_conversation_id(freshness: Freshness) -> Option<String> {
230 if !scope_enabled() {
231 return None;
232 }
233 // A subagent is its own scope (see `subagent_scope`) and that wins over the
234 // transcript id, so a subagent never inherits the parent's delivery identity
235 // — and never samples the transcript, so it can't feed the concurrency signal.
236 if let Some(scope) = subagent_scope() {
237 return Some(scope);
238 }
239 if let Ok(guard) = store().read()
240 && let Some(cached) = guard.as_ref()
241 && cache_is_usable(freshness, cached.refreshed_at.elapsed(), REFRESH_TTL)
242 {
243 return cached.value.clone();
244 }
245 refresh()
246}
247
248/// Pure core of the cache-vs-resample choice: a `Fresh` request always
249/// re-samples; a `Cached` one reuses an entry younger than `ttl`.
250fn cache_is_usable(freshness: Freshness, age: Duration, ttl: Duration) -> bool {
251 matches!(freshness, Freshness::Cached) && age < ttl
252}
253
254fn refresh() -> Option<String> {
255 let fresh = crate::core::host_state::load_active_transcript().and_then(|(_, conv)| conv);
256 if let Some(id) = fresh.as_deref() {
257 note_conversation_seen(id);
258 }
259 if let Ok(mut guard) = store().write() {
260 // Retain last-known-good: a transient miss (file briefly absent or
261 // expired) must not flip a stable conversation to `None` and force
262 // needless cold re-reads.
263 if fresh.is_none()
264 && let Some(existing) = guard.as_ref()
265 && existing.value.is_some()
266 {
267 let kept = existing.value.clone();
268 *guard = Some(Cached {
269 value: kept.clone(),
270 refreshed_at: Instant::now(),
271 });
272 return kept;
273 }
274 *guard = Some(Cached {
275 value: fresh.clone(),
276 refreshed_at: Instant::now(),
277 });
278 }
279 fresh
280}
281
282/// Record a sighting of `id` in the recency log. Thin wrapper over the pure
283/// [`note_into`] so the global store stays an implementation detail.
284fn note_conversation_seen(id: &str) {
285 if let Ok(mut v) = seen_store().write() {
286 note_into(&mut v, id, Instant::now(), CONCURRENCY_WINDOW);
287 }
288}
289
290/// Pure core of [`note_conversation_seen`]: upsert `id`'s last-seen timestamp and
291/// drop sightings older than `window`. One entry per id, so the vec length is the
292/// number of distinct recent conversations.
293fn note_into(v: &mut Vec<(String, Instant)>, id: &str, now: Instant, window: Duration) {
294 v.retain(|(_, t)| now.duration_since(*t) < window);
295 if let Some(entry) = v.iter_mut().find(|(seen, _)| seen == id) {
296 entry.1 = now;
297 } else {
298 v.push((id.to_string(), now));
299 }
300}
301
302/// Pure core of [`multiple_conversations_recent`]: distinct ids seen within
303/// `window` of `now`.
304fn distinct_within(v: &[(String, Instant)], now: Instant, window: Duration) -> usize {
305 v.iter()
306 .filter(|(_, t)| now.duration_since(*t) < window)
307 .count()
308}
309
310/// True when more than one conversation has been active within
311/// [`CONCURRENCY_WINDOW`] — i.e. the daemon is multiplexing chats, so the shared
312/// `active_transcript.json` id can't be trusted to name the current caller and no
313/// stub is provably in-context (#1040).
314pub(crate) fn multiple_conversations_recent() -> bool {
315 seen_store()
316 .read()
317 .is_ok_and(|v| distinct_within(&v, Instant::now(), CONCURRENCY_WINDOW) > 1)
318}
319
320/// Whether the active scope can name an *individual* caller.
321///
322/// #1801: the `proc:` scope was introduced for Claude Code (#1292) on the
323/// premise that "each MCP connection is a separate stdio process", so one
324/// process meant one consumer. That premise does not hold — Claude Code
325/// sub-agents reuse their parent's MCP connection and spawn no lean-ctx
326/// process of their own, so the parent and every sub-agent resolve the
327/// *identical* `proc:` id. A match therefore proves nothing about who is
328/// asking, and a stub served on it reaches a sub-agent whose context window
329/// never held the content.
330///
331/// This is the same hazard `multiple_conversations_recent` already fails
332/// closed on, so it gets the same treatment. `task:` (Cursor) and `custom:`
333/// (explicit override) do name one agent and stay unaffected; so does the
334/// legacy transcript path, where one daemon serves one conversation.
335pub(crate) fn scope_cannot_identify_caller() -> bool {
336 subagent_scope().is_some_and(|scope| scope.starts_with("proc:"))
337}
338
339/// Set once by a one-shot CLI process (`lean-ctx call`, #2060).
340static ONE_SHOT_PROCESS: AtomicBool = AtomicBool::new(false);
341
342/// Marks this process as one-shot: its output goes to a caller whose context
343/// holds nothing this process delivered, so no stub or cross-agent reference
344/// can be in context (#2060). Such a process still resolves the shared
345/// `active_transcript.json` conversation id, which made earlier deliveries
346/// look like its own.
347pub fn mark_one_shot_process() {
348 ONE_SHOT_PROCESS.store(true, Ordering::Relaxed);
349}
350
351/// Whether [`mark_one_shot_process`] ran in this process.
352pub fn is_one_shot_process() -> bool {
353 ONE_SHOT_PROCESS.load(Ordering::Relaxed)
354}
355
356/// Whether a `[unchanged]` stub may be served for an entry that was delivered to
357/// `delivered`, given the `current` conversation.
358///
359/// `current == None` (no conversation context) preserves the legacy
360/// process-scoped behavior — stub allowed — **unless** the caller cannot be
361/// identified: more than one conversation has been active recently (#1040), or
362/// the scope is only process-derived (#1801). Then every stub is withheld.
363///
364/// A one-shot process never serves a stub (#2060), in legacy mode included.
365pub fn conversation_allows_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
366 !ONE_SHOT_PROCESS.load(Ordering::Relaxed)
367 && allows_stub(
368 scope_enabled(),
369 multiple_conversations_recent() || scope_cannot_identify_caller(),
370 current,
371 delivered,
372 )
373}
374
375/// Pure decision core (no env / global reads) so the full matrix is unit-testable.
376///
377/// `unprovable` folds together every reason a matching id fails to prove the
378/// content is in *this* caller's context: several live chats (#1040) and a
379/// scope that cannot name one caller (#1801).
380fn allows_stub(
381 scope_on: bool,
382 unprovable: bool,
383 current: Option<&str>,
384 delivered: Option<&str>,
385) -> bool {
386 if !scope_on {
387 // Explicit legacy mode: one daemon == one conversation by contract.
388 return true;
389 }
390 if unprovable {
391 // A matching id can't be trusted to name THIS caller, so nothing is
392 // provably in-context — withhold every stub (#1040, #1801).
393 return false;
394 }
395 match (current, delivered) {
396 (Some(c), Some(d)) => c == d,
397 // Known caller, unknown delivery (pre-scoping entry) → can't prove → block.
398 (Some(_), None) => false,
399 // Unknown caller on a single-conversation daemon → legacy allow.
400 (None, _) => true,
401 }
402}
403
404/// Whether a *cold* `[unchanged]` stub may be served — i.e. one backed only by
405/// the persisted index ([`crate::core::read_stub_index`]) after a daemon
406/// restart, with no live in-memory entry.
407///
408/// Stricter than [`conversation_allows_stub`]: a cold stub crosses a process
409/// boundary, so we serve it **only** when both sides name the *same, known*
410/// conversation. Unlike the warm path there is no "no context → legacy" escape,
411/// because without a current conversation id we cannot prove the content is in
412/// the new process's context, and a wrong cold stub would resurrect exactly the
413/// cross-chat hazard #954 closed. Also withheld under concurrency (#1040).
414pub fn conversation_allows_cold_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
415 allows_cold_stub(
416 multiple_conversations_recent() || scope_cannot_identify_caller(),
417 current,
418 delivered,
419 )
420}
421
422/// Pure decision core of [`conversation_allows_cold_stub`]. `unprovable` carries
423/// the same meaning as in [`allows_stub`].
424fn allows_cold_stub(unprovable: bool, current: Option<&str>, delivered: Option<&str>) -> bool {
425 !unprovable && matches!((current, delivered), (Some(c), Some(d)) if c == d)
426}
427
428/// Detects a subagent (forked agent) execution context.
429///
430/// A subagent must never be served a stub for content only the parent received.
431/// That used to be enforced by force-freshing *every* subagent read; with
432/// conversation scoping (#954/#955) the subagent instead runs under its own scope
433/// ([`current_conversation_id`] → `task:{id}` or `proc:{id}`), so
434/// the stub gate withholds cross-agent stubs precisely while restoring the
435/// subagent's *own* cheap re-reads. The blanket force-fresh is therefore kept
436/// only as the fallback when scoping is disabled (#956).
437///
438/// Checks `CURSOR_TASK_ID` (Cursor) and `CLAUDE_CODE_ENTRYPOINT=local-agent`
439/// (future Claude Code subagent marker). Current Claude Code is handled by
440/// per-process scoping in this module (#1292).
441pub fn is_subagent_context() -> bool {
442 static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
443 *IS_SUBAGENT.get_or_init(|| {
444 std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
445 || std::env::var("CLAUDE_CODE_ENTRYPOINT")
446 .ok()
447 .as_deref()
448 .map(str::trim)
449 == Some("local-agent")
450 })
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 // The gate matrix is tested through the pure cores (`allows_stub` /
458 // `allows_cold_stub`) so the assertions are deterministic regardless of the
459 // process-global recency store, which other tests mutate in parallel.
460
461 /// #1801: a `proc:` scope is shared by the parent and every Claude Code
462 /// sub-agent, so identical ids prove nothing about who is asking. The gate
463 /// must withhold exactly as it does for concurrent chats — otherwise a
464 /// sub-agent receives a reference to content it has never seen.
465 #[test]
466 fn process_derived_scope_withholds_stubs_even_when_ids_match() {
467 let scope = "proc:4242-1700000000";
468 assert!(
469 !allows_stub(true, true, Some(scope), Some(scope)),
470 "a shared process scope must not authorize a stub"
471 );
472 assert!(
473 !allows_cold_stub(true, Some(scope), Some(scope)),
474 "the cold path must withhold for the same reason"
475 );
476 }
477
478 /// A scope that *does* name one agent keeps its savings: Cursor's `task:`
479 /// and an explicit `custom:` override are per-agent by construction.
480 #[test]
481 fn per_agent_scopes_still_allow_stubs() {
482 for scope in ["task:abc123", "custom:my-worker"] {
483 assert!(
484 allows_stub(true, false, Some(scope), Some(scope)),
485 "{scope} identifies one caller and must keep deduplicating"
486 );
487 }
488 }
489
490 #[test]
491 fn only_process_scopes_are_treated_as_unidentifiable() {
492 assert!(subagent_scope_from(Some("abc")).is_some_and(|s| !s.starts_with("proc:")));
493 assert!(
494 resolve_scope(None, Some("1"), None, "4242-1700000000")
495 .is_some_and(|s| s.starts_with("proc:")),
496 "Claude Code without a per-subagent id must resolve to a proc: scope"
497 );
498 }
499
500 #[test]
501 fn no_current_context_allows_stub_legacy() {
502 assert!(allows_stub(true, false, None, None));
503 assert!(allows_stub(true, false, None, Some("conv-a")));
504 }
505
506 #[test]
507 fn scope_disabled_always_allows_stub() {
508 // Explicit opt-out → legacy process scope, even under (irrelevant) concurrency.
509 assert!(allows_stub(false, true, Some("conv-b"), Some("conv-a")));
510 assert!(allows_stub(false, true, None, None));
511 }
512
513 #[test]
514 fn same_conversation_allows_stub() {
515 assert!(allows_stub(true, false, Some("conv-a"), Some("conv-a")));
516 }
517
518 #[test]
519 fn different_conversation_blocks_stub() {
520 assert!(!allows_stub(true, false, Some("conv-b"), Some("conv-a")));
521 }
522
523 #[test]
524 fn unknown_delivering_conversation_blocks_stub() {
525 // Entry delivered before scoping existed → cannot prove it is in context.
526 assert!(!allows_stub(true, false, Some("conv-a"), None));
527 }
528
529 #[test]
530 fn concurrency_withholds_every_warm_stub() {
531 // #1040: while >1 chat is live, even a same-id match is untrustworthy.
532 assert!(!allows_stub(true, true, Some("conv-a"), Some("conv-a")));
533 assert!(!allows_stub(true, true, None, None));
534 assert!(!allows_stub(true, true, None, Some("conv-a")));
535 }
536
537 #[test]
538 fn cold_stub_requires_both_known_and_matching() {
539 assert!(allows_cold_stub(false, Some("c"), Some("c")));
540 assert!(!allows_cold_stub(false, Some("c"), Some("d")));
541 // No "legacy" escape for the cold path: unknown either side → blocked.
542 assert!(!allows_cold_stub(false, None, Some("c")));
543 assert!(!allows_cold_stub(false, Some("c"), None));
544 assert!(!allows_cold_stub(false, None, None));
545 }
546
547 #[test]
548 fn cold_stub_withheld_under_concurrency() {
549 // #1040: a matching cold stub is still withheld while chats are multiplexed.
550 assert!(!allows_cold_stub(true, Some("c"), Some("c")));
551 }
552
553 #[test]
554 fn fresh_request_resamples_cached_request_honors_ttl() {
555 let ttl = Duration::from_secs(3);
556 // `Fresh` ignores the cache even for a brand-new entry → always re-samples,
557 // so the stub gate sees a just-appeared second chat with zero lag (#1042).
558 assert!(!cache_is_usable(
559 Freshness::Fresh,
560 Duration::from_millis(0),
561 ttl
562 ));
563 assert!(!cache_is_usable(Freshness::Fresh, ttl * 100, ttl));
564 // `Cached` reuses a within-TTL entry but re-samples once it has expired
565 // (age == ttl is already expired: the window is a strict `<`).
566 assert!(cache_is_usable(
567 Freshness::Cached,
568 Duration::from_secs(1),
569 ttl
570 ));
571 assert!(!cache_is_usable(Freshness::Cached, ttl, ttl));
572 assert!(!cache_is_usable(Freshness::Cached, ttl * 2, ttl));
573 }
574
575 #[test]
576 fn recency_counts_distinct_ids_and_dedupes_repeats() {
577 let now = Instant::now();
578 let win = Duration::from_secs(30);
579 let mut v = Vec::new();
580 note_into(&mut v, "a", now, win);
581 assert_eq!(distinct_within(&v, now, win), 1);
582 note_into(&mut v, "a", now, win); // same id refreshes, no new entry
583 assert_eq!(distinct_within(&v, now, win), 1);
584 note_into(&mut v, "b", now, win); // second chat → concurrency
585 assert_eq!(distinct_within(&v, now, win), 2);
586 }
587
588 #[test]
589 fn recency_prunes_sightings_older_than_window() {
590 let win = Duration::from_secs(30);
591 let t0 = Instant::now();
592 let mut v = Vec::new();
593 note_into(&mut v, "old", t0, win);
594 // A sighting well past the window prunes the stale one — back to one chat.
595 let t1 = t0 + win * 2;
596 note_into(&mut v, "new", t1, win);
597 assert_eq!(distinct_within(&v, t1, win), 1);
598 }
599
600 #[test]
601 fn subagent_scope_derives_a_distinct_non_none_id_from_task() {
602 assert_eq!(
603 subagent_scope_from(Some("abc123")),
604 Some("task:abc123".to_string())
605 );
606 assert_eq!(
607 subagent_scope_from(Some(" abc ")),
608 Some("task:abc".to_string())
609 );
610 assert_eq!(subagent_scope_from(Some("")), None);
611 assert_eq!(subagent_scope_from(Some(" ")), None);
612 assert_eq!(subagent_scope_from(None), None);
613 }
614
615 #[test]
616 fn subagent_scope_never_matches_a_plain_conversation() {
617 let sub = subagent_scope_from(Some("xyz")).unwrap();
618 assert!(!allows_stub(true, false, Some(&sub), Some("xyz")));
619 assert!(allows_stub(true, false, Some(&sub), Some(&sub)));
620 }
621
622 // --- resolve_scope (multi-host) tests (#1292) ---
623
624 #[test]
625 fn resolve_scope_explicit_override_wins() {
626 let s = resolve_scope(Some("task-1"), Some("1"), Some("my-scope"), "42-99");
627 assert_eq!(s, Some("custom:my-scope".to_string()));
628 }
629
630 #[test]
631 fn resolve_scope_cursor_task_id_beats_claude_code() {
632 let s = resolve_scope(Some("task-1"), Some("1"), None, "42-99");
633 assert_eq!(s, Some("task:task-1".to_string()));
634 }
635
636 #[test]
637 fn resolve_scope_claude_code_gets_process_scope() {
638 let s = resolve_scope(None, Some("1"), None, "100-111");
639 assert_eq!(s, Some("proc:100-111".to_string()));
640 }
641
642 #[test]
643 fn resolve_scope_no_agent_env_returns_none() {
644 assert_eq!(resolve_scope(None, None, None, "42-99"), None);
645 }
646
647 #[test]
648 fn resolve_scope_claude_code_different_processes_differ() {
649 let s1 = resolve_scope(None, Some("1"), None, "100-111").unwrap();
650 let s2 = resolve_scope(None, Some("1"), None, "200-222").unwrap();
651 assert_ne!(
652 s1, s2,
653 "different lean-ctx processes must get different scopes"
654 );
655 }
656
657 #[test]
658 fn claude_code_process_scope_never_matches_session_id() {
659 let scope = resolve_scope(None, Some("1"), None, "42-12345").unwrap();
660 assert!(!allows_stub(true, false, Some(&scope), Some("sess-abc")));
661 assert!(allows_stub(true, false, Some(&scope), Some(&scope)));
662 }
663
664 #[test]
665 fn claude_code_parent_and_subagent_stubs_isolated() {
666 let parent = resolve_scope(None, Some("1"), None, "100-111").unwrap();
667 let child = resolve_scope(None, Some("1"), None, "200-222").unwrap();
668 assert!(allows_stub(true, false, Some(&parent), Some(&parent)));
669 assert!(!allows_stub(true, false, Some(&child), Some(&parent)));
670 assert!(!allows_cold_stub(false, Some(&child), Some(&parent)));
671 }
672
673 #[test]
674 fn resolve_scope_blank_explicit_override_ignored() {
675 assert_eq!(resolve_scope(None, None, Some(" "), "42-99"), None);
676 assert_eq!(resolve_scope(None, None, Some(""), "42-99"), None);
677 }
678}