Skip to main content

thndrs_agent/context/
selection.rs

1//! Context selection policy: build candidate ledger items and select the
2//! working set for a turn.
3//!
4//! This is a pure policy layer. It consumes already-loaded candidate sources
5//! (harness fragments, instruction selections, pins, compaction
6//! summaries, transcript entries, skills) and produces a [`ContextLedger`]
7//! whose items carry a [`ContextVisibility`] and a human-readable `reason`
8//! for every inclusion or omission decision. Side effects belong to the caller.
9//!
10//! ## Selection order
11//!
12//! 1. Always-loaded harness context (visible, never evicted by budget).
13//! 2. Current user turn (visible, outside ordinary budget eviction).
14//! 3. Active pins (pinned, before ordinary transcript items).
15//! 4. Applicable closest `AGENTS.md` before broader guidance (visible).
16//! 6. Active skill instructions then discovered skill metadata (visible).
17//! 7. Latest compaction summary (visible) when older transcript turns are
18//!    omitted; older summaries stay archived.
19//! 8. Recent transcript entries (visible) within budget, oldest evicted
20//!    first under pressure.
21//!
22//! UI-only and live-only transcript entries are never selected. Items whose
23//! token estimate alone would exceed the available input budget are marked
24//! [`ContextVisibility::Blocked`] instead of being silently truncated.
25
26use std::path::PathBuf;
27
28use super::support::{ratio_of, scope_depth};
29use crate::context::{
30    ContextBudget, ContextDiagnostic, ContextItem, ContextItemKind, ContextLedger, ContextVisibility,
31    DiagnosticSeverity, ModelContextLimits, estimate_tokens, item_id_for_path, item_id_for_session_range,
32};
33
34/// A transcript entry considered for selection.
35///
36/// Carries the stable sequence number used for ids and eviction ordering, the
37/// kind label for inclusion/omission rules, and an estimated byte size so the
38/// policy stays free of the rendering layer.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct TranscriptCandidate {
41    /// Monotonic sequence number within the session (0-based).
42    pub seq: u64,
43    /// Session id scoping this entry's item id.
44    pub session_id: String,
45    /// Short label (e.g. `"user"`, `"assistant"`, `"tool:read_file"`).
46    pub label: String,
47    /// Estimated UTF-8 byte size of the rendered entry content.
48    pub bytes: usize,
49    /// Stable handle for bounded redacted recovery of this transcript item.
50    pub artifact_handle: Option<String>,
51    /// Whether the entry is UI-only (status/error rows) or live-only (streaming).
52    pub ui_only: bool,
53    /// Whether the entry is still streaming and not yet settled.
54    pub streaming: bool,
55}
56
57impl TranscriptCandidate {
58    /// Build a settled transcript candidate.
59    pub fn new(session_id: impl Into<String>, seq: u64, label: impl Into<String>, bytes: usize) -> Self {
60        TranscriptCandidate {
61            seq,
62            session_id: session_id.into(),
63            label: label.into(),
64            bytes,
65            artifact_handle: None,
66            ui_only: false,
67            streaming: false,
68        }
69    }
70
71    /// Mark this candidate as UI-only (status/error rows).
72    pub fn ui_only(mut self) -> Self {
73        self.ui_only = true;
74        self
75    }
76
77    /// Mark this candidate as still streaming and not yet settled.
78    pub fn streaming(mut self) -> Self {
79        self.streaming = true;
80        self
81    }
82}
83
84/// A pinned working-set item considered for selection.
85///
86/// Pins are task-local evidence kept visible across turns until dropped or
87/// expired. They are not durable context.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct PinnedCandidate {
90    /// Stable id from [`item_id_for_path`] or an explicit handle.
91    pub id: String,
92    /// Kind of pinned context.
93    pub kind: ContextItemKind,
94    /// Short label (e.g. file path, tool-result label).
95    pub label: String,
96    /// Absolute source path when file-backed.
97    pub source_path: Option<PathBuf>,
98    /// Scope label.
99    pub scope: String,
100    /// Content hash when applicable.
101    pub content_hash: Option<u64>,
102    /// Stable handle for bounded redacted recovery, when this pin is evidence.
103    pub artifact_handle: Option<String>,
104    /// Estimated UTF-8 byte size of the pinned content.
105    pub bytes: usize,
106}
107
108impl PinnedCandidate {
109    /// Build a pinned file candidate with a stable path-derived id.
110    pub fn file(kind: ContextItemKind, path: PathBuf, scope: impl Into<String>, bytes: usize) -> Self {
111        let id = item_id_for_path(&kind, &path);
112        let label = path.display().to_string();
113        PinnedCandidate {
114            id,
115            kind,
116            label,
117            source_path: Some(path),
118            scope: scope.into(),
119            content_hash: None,
120            artifact_handle: None,
121            bytes,
122        }
123    }
124}
125
126/// A compaction summary considered for selection.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct CompactionSummaryCandidate {
129    /// Stable id for the summary range.
130    pub id: String,
131    /// Inclusive start sequence covered by the summary.
132    pub start_seq: u64,
133    /// Inclusive end sequence covered by the summary.
134    pub end_seq: u64,
135    /// Short label (e.g. `"summary 12..47"`).
136    pub label: String,
137    /// Renderable summary text, used for prompt projection when the summary is
138    /// selected as normal context content. `None` when only metadata is
139    /// needed.
140    pub content: Option<String>,
141    /// Estimated UTF-8 byte size of the summary text.
142    pub bytes: usize,
143    /// Whether this summary is the latest compaction.
144    pub latest: bool,
145}
146
147impl CompactionSummaryCandidate {
148    /// Build a summary candidate covering `start_seq..=end_seq` in `session_id`.
149    pub fn new(session_id: &str, start_seq: u64, end_seq: u64, bytes: usize, latest: bool) -> Self {
150        let id = item_id_for_session_range(&ContextItemKind::Summary, session_id, start_seq, end_seq);
151        CompactionSummaryCandidate {
152            id,
153            start_seq,
154            end_seq,
155            label: format!("summary {start_seq}..{end_seq}"),
156            content: None,
157            bytes,
158            latest,
159        }
160    }
161}
162
163/// A loaded skill considered for selection.
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub struct SkillCandidate {
166    /// Skill name.
167    pub name: String,
168    /// Absolute path to the `SKILL.md`.
169    pub path: PathBuf,
170    /// Content hash of the loaded skill text.
171    pub content_hash: u64,
172    /// Estimated UTF-8 byte size of the loaded skill instructions.
173    pub bytes: usize,
174    /// Whether the skill instructions are fully loaded (`true`) or only
175    /// discovered as metadata (`false`).
176    pub loaded: bool,
177}
178
179impl SkillCandidate {
180    /// Build a discovered-only skill candidate from its stable metadata.
181    pub fn discovered(name: impl Into<String>, path: PathBuf, content_hash: u64, bytes: usize) -> Self {
182        SkillCandidate { name: name.into(), path, content_hash, bytes, loaded: false }
183    }
184
185    /// Build a fully-loaded skill candidate.
186    pub fn loaded(name: impl Into<String>, path: PathBuf, rendered_bytes: usize, rendered_hash: u64) -> Self {
187        SkillCandidate { name: name.into(), path, content_hash: rendered_hash, bytes: rendered_bytes, loaded: true }
188    }
189}
190
191/// An instruction source considered for selection.
192///
193/// Mirrors the fields the policy needs from an application's instruction-source
194/// type without forcing the caller to clone full content; selection works on
195/// metadata only.
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct InstructionCandidate {
198    /// Absolute source path.
199    pub path: PathBuf,
200    /// Scope label — `"."` for root, or a relative subtree path.
201    pub scope: String,
202    /// Content hash of the full source.
203    pub content_hash: u64,
204    /// Original byte count (before any truncation at load time).
205    pub byte_count: usize,
206    /// Renderable instruction text (size-capped), used for prompt projection
207    /// when the source is selected as normal context content. `None` when only
208    /// metadata is needed.
209    pub content: Option<String>,
210    /// Whether the source was truncated when loaded.
211    pub truncated: bool,
212    /// Whether the source is applicable this turn (closest-first ordering is
213    /// the caller's responsibility).
214    pub applicable: bool,
215}
216
217impl InstructionCandidate {
218    /// Depth of the scope: `.` = 0, `src` = 1, `src/core` = 2.
219    pub fn scope_depth(&self) -> usize {
220        scope_depth(&self.scope)
221    }
222}
223
224/// A harness context fragment considered for selection.
225///
226/// Harness fragments are always-loaded and never evicted by the budget policy;
227/// they are recorded so the ledger is honest about what the model sees.
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct HarnessCandidate {
230    /// Stable id for the fragment.
231    pub id: String,
232    /// Short label (e.g. `"base_identity"`, `"tool_schemas"`).
233    pub label: String,
234    /// Estimated UTF-8 byte size of the fragment.
235    pub bytes: usize,
236}
237
238impl HarnessCandidate {
239    /// Build a harness fragment candidate.
240    pub fn new(label: impl Into<String>, bytes: usize) -> Self {
241        let label = label.into();
242        let id = format!("ctx_harness_{}", slug(&label));
243        HarnessCandidate { id, label, bytes }
244    }
245}
246
247/// All candidate sources for a turn, ready for selection.
248///
249/// Built by the caller from boundary work (filesystem discovery and transcript
250/// projection). The policy consumes this without further
251/// I/O.
252#[derive(Clone, Debug, Default, Eq, PartialEq)]
253pub struct SelectionInput {
254    /// Always-loaded harness fragments.
255    pub harness: Vec<HarnessCandidate>,
256    /// Current user turn text, outside ordinary budget eviction.
257    pub user_turn: Option<UserTurnCandidate>,
258    /// Scoped project instructions, closest-first.
259    pub instructions: Vec<InstructionCandidate>,
260    /// Active pins.
261    pub pins: Vec<PinnedCandidate>,
262    /// Compaction summaries, latest last.
263    pub compaction_summaries: Vec<CompactionSummaryCandidate>,
264    /// Recent transcript candidates, oldest first.
265    pub transcript: Vec<TranscriptCandidate>,
266    /// Discovered and loaded skills.
267    pub skills: Vec<SkillCandidate>,
268    /// Explicitly dropped item ids (persist until source change or reset).
269    pub dropped_ids: Vec<String>,
270}
271
272/// The current user turn, kept outside ordinary budget eviction.
273#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct UserTurnCandidate {
275    /// Stable id for the turn within the session.
276    pub id: String,
277    /// Estimated UTF-8 byte size of the user turn text.
278    pub bytes: usize,
279}
280
281impl UserTurnCandidate {
282    /// Build a user turn candidate with a session-scoped id.
283    pub fn new(session_id: &str, seq: u64, bytes: usize) -> Self {
284        UserTurnCandidate { id: item_id_for_session_range(&ContextItemKind::Transcript, session_id, seq, seq), bytes }
285    }
286}
287
288/// Select the context working set for a turn.
289///
290/// Builds a [`ContextLedger`] from [`SelectionInput`] and the resolved
291/// [`ModelContextLimits`]. The policy is deterministic for the same inputs.
292#[expect(
293    clippy::cognitive_complexity,
294    reason = "The selection order is intentionally explicit so every candidate receives an auditable state and reason."
295)]
296pub fn select_context(input: &SelectionInput, limits: ModelContextLimits) -> ContextLedger {
297    let mut items: Vec<ContextItem> = Vec::new();
298    let mut diagnostics = Vec::new();
299
300    let available_input = limits.available_input_budget();
301
302    for harness in &input.harness {
303        items.push(ContextItem {
304            id: harness.id.clone(),
305            kind: ContextItemKind::Harness,
306            label: harness.label.clone(),
307            source_path: None,
308            scope: ".".to_string(),
309            content_hash: None,
310            artifact_handle: None,
311            byte_count: harness.bytes,
312            content: None,
313            token_estimate: estimate_tokens(harness.bytes),
314            visibility: ContextVisibility::Visible,
315            reason_code: "harness_always_loaded".to_string(),
316            reason: "always-loaded harness context".to_string(),
317        });
318    }
319
320    if let Some(turn) = &input.user_turn {
321        items.push(ContextItem {
322            id: turn.id.clone(),
323            kind: ContextItemKind::Transcript,
324            label: "current user turn".to_string(),
325            source_path: None,
326            scope: ".".to_string(),
327            content_hash: None,
328            artifact_handle: None,
329            byte_count: turn.bytes,
330            content: None,
331            token_estimate: estimate_tokens(turn.bytes),
332            visibility: ContextVisibility::Visible,
333            reason_code: "current_user_turn".to_string(),
334            reason: "current user turn, outside ordinary budget eviction".to_string(),
335        });
336    }
337
338    for pin in &input.pins {
339        let visibility = if input.dropped_ids.iter().any(|id| id == &pin.id) {
340            ContextVisibility::Dropped
341        } else if estimate_tokens(pin.bytes) as u64 > available_input {
342            diagnostics.push(blocked_oversized(&pin.id, "pinned item"));
343            ContextVisibility::Blocked
344        } else {
345            ContextVisibility::Pinned
346        };
347        let reason = visibility.reason("user pin");
348        let reason_code = match visibility {
349            ContextVisibility::Dropped => "explicit_drop",
350            ContextVisibility::Blocked => "pinned_item_over_budget",
351            ContextVisibility::Pinned => "user_pin",
352            _ => "user_pin",
353        };
354        items.push(ContextItem {
355            id: pin.id.clone(),
356            kind: pin.kind.clone(),
357            label: pin.label.clone(),
358            source_path: pin.source_path.clone(),
359            scope: pin.scope.clone(),
360            content_hash: pin.content_hash,
361            artifact_handle: pin.artifact_handle.clone(),
362            byte_count: pin.bytes,
363            content: None,
364            token_estimate: estimate_tokens(pin.bytes),
365            visibility,
366            reason_code: reason_code.to_string(),
367            reason,
368        });
369    }
370
371    let mut ordered_instructions: Vec<&InstructionCandidate> = input.instructions.iter().collect();
372    ordered_instructions.sort_by_key(|b| std::cmp::Reverse(b.scope_depth()));
373    for instruction in ordered_instructions {
374        let id = item_id_for_path(&ContextItemKind::ProjectInstruction, &instruction.path);
375        let visibility = if input.dropped_ids.iter().any(|d| d == &id) {
376            ContextVisibility::Dropped
377        } else if !instruction.applicable {
378            ContextVisibility::Candidate
379        } else if estimate_tokens(instruction.byte_count) as u64 > available_input {
380            diagnostics.push(blocked_oversized(&id, "project instruction"));
381            ContextVisibility::Blocked
382        } else {
383            ContextVisibility::Visible
384        };
385        let reason =
386            visibility.reason(if instruction.applicable { "applicable AGENTS.md" } else { "discovered AGENTS.md" });
387        let reason_code = match visibility {
388            ContextVisibility::Dropped => "explicit_drop",
389            ContextVisibility::Blocked => "project_instruction_over_budget",
390            ContextVisibility::Candidate => "instruction_not_applicable",
391            _ => "applicable_project_instruction",
392        };
393        items.push(ContextItem {
394            id,
395            kind: ContextItemKind::ProjectInstruction,
396            label: instruction.path.display().to_string(),
397            source_path: Some(instruction.path.clone()),
398            scope: instruction.scope.clone(),
399            content_hash: Some(instruction.content_hash),
400            artifact_handle: None,
401            byte_count: instruction.byte_count,
402            content: if visibility.is_rendered() { instruction.content.clone() } else { None },
403            token_estimate: estimate_tokens(instruction.byte_count),
404            visibility,
405            reason_code: reason_code.to_string(),
406            reason,
407        });
408    }
409
410    let mut ordered_skills: Vec<&SkillCandidate> = input.skills.iter().collect();
411    ordered_skills.sort_by_key(|s| !s.loaded);
412    for skill in ordered_skills {
413        let id = item_id_for_path(&ContextItemKind::Skill, &skill.path);
414        let visibility = if input.dropped_ids.iter().any(|d| d == &id) {
415            ContextVisibility::Dropped
416        } else if estimate_tokens(skill.bytes) as u64 > available_input {
417            diagnostics.push(blocked_oversized(&id, "skill"));
418            ContextVisibility::Blocked
419        } else if skill.loaded {
420            ContextVisibility::Visible
421        } else {
422            ContextVisibility::Candidate
423        };
424        let reason = visibility.reason(if skill.loaded { "loaded skill" } else { "discovered skill metadata" });
425        let reason_code = match visibility {
426            ContextVisibility::Dropped => "explicit_drop",
427            ContextVisibility::Blocked => "skill_over_budget",
428            ContextVisibility::Candidate => "skill_metadata_only",
429            _ => "skill_loaded",
430        };
431        items.push(ContextItem {
432            id,
433            kind: ContextItemKind::Skill,
434            label: skill.name.clone(),
435            source_path: Some(skill.path.clone()),
436            scope: ".".to_string(),
437            content_hash: Some(skill.content_hash),
438            artifact_handle: None,
439            byte_count: skill.bytes,
440            content: None,
441            token_estimate: estimate_tokens(skill.bytes),
442            visibility,
443            reason_code: reason_code.to_string(),
444            reason,
445        });
446    }
447
448    let omitting_older = should_omit_older_transcript(input, available_input);
449    for summary in &input.compaction_summaries {
450        let visibility = if input.dropped_ids.iter().any(|d| d == &summary.id) {
451            ContextVisibility::Dropped
452        } else if !omitting_older {
453            ContextVisibility::Archived
454        } else if summary.latest {
455            if estimate_tokens(summary.bytes) as u64 > available_input {
456                diagnostics.push(blocked_oversized(&summary.id, "compaction summary"));
457                ContextVisibility::Blocked
458            } else {
459                ContextVisibility::Visible
460            }
461        } else {
462            ContextVisibility::Archived
463        };
464        let reason = visibility.reason("compaction summary");
465        let reason_code = match visibility {
466            ContextVisibility::Dropped => "explicit_drop",
467            ContextVisibility::Blocked => "summary_over_budget",
468            ContextVisibility::Archived => "summary_not_current",
469            _ => "latest_compaction_summary",
470        };
471        items.push(ContextItem {
472            id: summary.id.clone(),
473            kind: ContextItemKind::Summary,
474            label: summary.label.clone(),
475            source_path: None,
476            scope: ".".to_string(),
477            content_hash: None,
478            artifact_handle: None,
479            byte_count: summary.bytes,
480            content: if visibility.is_rendered() { summary.content.clone() } else { None },
481            token_estimate: estimate_tokens(summary.bytes),
482            visibility,
483            reason_code: reason_code.to_string(),
484            reason,
485        });
486    }
487
488    push_transcript(&mut items, input, available_input);
489
490    let budget = ContextBudget::from_limits(limits, &items);
491    ContextLedger { items, budget, diagnostics }
492}
493
494/// Push transcript candidates, omitting UI-only and live-only entries and
495/// evicting oldest entries under budget pressure.
496fn push_transcript(items: &mut Vec<ContextItem>, input: &SelectionInput, available_input: u64) {
497    let target = ratio_of(available_input, super::control::TARGET_BUDGET_RATIO);
498
499    let consumed: u64 = items
500        .iter()
501        .filter(|item| item.visibility.is_rendered())
502        .map(|item| item.token_estimate as u64)
503        .sum();
504
505    let transcript: Vec<&TranscriptCandidate> = input.transcript.iter().collect();
506    let mut selected: Vec<&TranscriptCandidate> = Vec::new();
507    let mut running = consumed;
508    for candidate in transcript.iter().rev() {
509        if candidate.ui_only || candidate.streaming {
510            continue;
511        }
512        let tokens = estimate_tokens(candidate.bytes) as u64;
513        if running + tokens > target {
514            break;
515        }
516        running += tokens;
517        selected.push(candidate);
518    }
519
520    let selected_seq: std::collections::HashSet<u64> = selected.iter().map(|c| c.seq).collect();
521
522    for candidate in &transcript {
523        let id = item_id_for_session_range(
524            &ContextItemKind::Transcript,
525            &candidate.session_id,
526            candidate.seq,
527            candidate.seq,
528        );
529        let (visibility, reason_code, reason) = if candidate.ui_only {
530            (
531                ContextVisibility::Candidate,
532                "ui_only_transcript",
533                "omitted: ui-only transcript entry",
534            )
535        } else if candidate.streaming {
536            (
537                ContextVisibility::Candidate,
538                "live_only_transcript",
539                "omitted: live-only streaming entry",
540            )
541        } else if input.dropped_ids.iter().any(|d| d == &id) {
542            (ContextVisibility::Dropped, "explicit_drop", "explicit drop")
543        } else if selected_seq.contains(&candidate.seq) {
544            (
545                ContextVisibility::Visible,
546                "recent_transcript",
547                "recent transcript entry",
548            )
549        } else {
550            (
551                ContextVisibility::Archived,
552                "evicted_under_budget",
553                "archived: evicted under budget pressure",
554            )
555        };
556        items.push(ContextItem {
557            id,
558            kind: ContextItemKind::Transcript,
559            label: candidate.label.clone(),
560            source_path: None,
561            scope: ".".to_string(),
562            content_hash: None,
563            artifact_handle: candidate.artifact_handle.clone(),
564            byte_count: candidate.bytes,
565            content: None,
566            token_estimate: estimate_tokens(candidate.bytes),
567            visibility,
568            reason_code: reason_code.to_string(),
569            reason: reason.to_string(),
570        });
571    }
572}
573
574/// Whether older transcript turns should be omitted (summarized) this turn.
575///
576/// Omits when a latest compaction summary is available and the transcript
577/// entries' own token cost would exceed the target selection budget.
578///
579/// When omitted, the latest summary stands in for the older transcript detail.
580fn should_omit_older_transcript(input: &SelectionInput, available_input: u64) -> bool {
581    if input.compaction_summaries.iter().all(|s| !s.latest) {
582        return false;
583    }
584    let target = ratio_of(available_input, super::control::TARGET_BUDGET_RATIO);
585    let transcript_tokens: u64 = input
586        .transcript
587        .iter()
588        .filter(|c| !c.ui_only && !c.streaming)
589        .map(|c| estimate_tokens(c.bytes) as u64)
590        .sum();
591    transcript_tokens > target
592}
593
594/// Slugify a label for use in an id.
595fn slug(label: &str) -> String {
596    label
597        .chars()
598        .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
599        .collect::<String>()
600        .trim_matches('_')
601        .to_string()
602}
603
604/// Build a diagnostic for an item blocked because its token estimate alone
605/// exceeds the available input budget.
606fn blocked_oversized(id: &str, kind: &str) -> ContextDiagnostic {
607    ContextDiagnostic {
608        severity: DiagnosticSeverity::Warning,
609        code: "blocked_oversized".to_string(),
610        message: format!("{kind} {id} exceeds available input budget; marked blocked instead of truncating"),
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::context::{ModelLimitConfidence, ModelLimitSource};
618
619    fn limits(context_window: u64) -> ModelContextLimits {
620        ModelContextLimits {
621            provider: "test".to_string(),
622            model: "test".to_string(),
623            context_window,
624            max_completion_tokens: 1_024,
625            recommended_completion_tokens: 512,
626            source: ModelLimitSource::LiveMetadata,
627            confidence: ModelLimitConfidence::Exact,
628        }
629    }
630
631    #[test]
632    fn pin_is_selected_and_can_be_dropped() {
633        let pin = PinnedCandidate::file(
634            ContextItemKind::PinnedFile,
635            PathBuf::from("/repo/src/lib.rs"),
636            "src",
637            120,
638        );
639        let input = SelectionInput { pins: vec![pin.clone()], ..Default::default() };
640        let visible = select_context(&input, limits(200_000));
641        assert!(
642            visible
643                .items
644                .iter()
645                .any(|item| item.id == pin.id && item.visibility == ContextVisibility::Pinned)
646        );
647
648        let dropped = SelectionInput { dropped_ids: vec![pin.id], ..input };
649        let ledger = select_context(&dropped, limits(200_000));
650        assert!(
651            ledger
652                .items
653                .iter()
654                .any(|item| item.visibility == ContextVisibility::Dropped)
655        );
656    }
657
658    #[test]
659    fn latest_summary_replaces_older_transcript_under_pressure() {
660        let input = SelectionInput {
661            harness: vec![HarnessCandidate::new("base", 1_000)],
662            user_turn: Some(UserTurnCandidate::new("session", 0, 100)),
663            compaction_summaries: vec![CompactionSummaryCandidate::new("session", 1, 10, 300, true)],
664            transcript: vec![
665                TranscriptCandidate::new("session", 1, "old", 5_000),
666                TranscriptCandidate::new("session", 2, "recent", 100),
667            ],
668            ..Default::default()
669        };
670        let ledger = select_context(&input, limits(4_000));
671        assert!(
672            ledger
673                .items
674                .iter()
675                .any(|item| item.kind == ContextItemKind::Summary && item.visibility == ContextVisibility::Visible)
676        );
677        assert!(
678            ledger
679                .items
680                .iter()
681                .any(|item| item.kind == ContextItemKind::Transcript && item.visibility == ContextVisibility::Archived)
682        );
683    }
684}