supercode-reduce 0.4.11

Optional lossless, reversible session reduction for Supercode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
//! TR-9 (T24): `/handoff` as extreme projection — "Amp retired
//! summarization-compaction for /handoff: spawn a new thread seeded with a
//! drafted objective + curated relevant artifacts, old thread untouched."
//! (TOKEN-REDUCTION-TECHNIQUES.md@ad3f2a4 §T24). In supercode a handoff is
//! NOT a new session lineage — it is a named [`ReductionLog`] snapshot over
//! the SAME sidecar: the projected view collapses to (1) a handoff banner,
//! (2) an objective paragraph, and (3) a curated keep-set, with EVERY OTHER
//! turn covered by one [`ReductionKind::TurnsCleared`] span per contiguous
//! gap between kept turns.
//!
//! # Design: reusing `TurnsCleared`, not inventing a new kind
//!
//! A handoff keep-set is generally scattered (system prompt + some
//! user-named early turns + the last K turns), so the "everything else"
//! complement is generally MULTIPLE disjoint ranges, not one. Rather than a
//! dedicated `HandoffCleared` reduction kind, this reuses
//! [`ReductionKind::TurnsCleared`] verbatim — one record per gap — which
//! required exactly one change to [`super::project_messages`] itself: its
//! reapplication path used to assume "at most one `TurnsCleared` reduction,
//! ever" (true for the auto-compactor, `Agent::maybe_compact`); it now
//! reapplies EVERY existing `TurnsCleared` record it finds, which is a
//! strict, backward-compatible generalization (a log with zero or one such
//! record behaves identically to before). `invert`/`verify_log`/
//! `expand_reduction`/`sidecar_search` already treated `log.reductions` as an
//! unordered bag keyed by `id`/address — those needed NO changes at all.
//!
//! # Banner/objective are view-only, never in the sidecar
//!
//! The banner+objective text is never a [`Reduction`] and never recorded in
//! the [`ReductionLog`] — it is folded into the projected VIEW's leading
//! system-role message only (merged into an existing leading system message
//! if the canonical session has one, else inserted as a new one), exactly
//! like TR-7's [`super::SpanSummary`] audit metadata: view-layer only,
//! `invert`/`verify_log` never look at it, and it structurally cannot reach
//! `supercode-core`'s session export adapters (which rebuild
//! **only** from the sidecar, never from a projected view).
//!
//! # Fresh projection, not an incremental one
//!
//! [`build_handoff`] deliberately builds its handoff view from a FRESH
//! projection (`ReductionLog::default()`) over the canonical session rather
//! than reapplying whatever reduction log the session already had — a
//! handoff is "one projection event" (SPEC.md C7's own framing), so kept
//! turns show their full, untouched content (never stacked on top of
//! whatever had been truncated/deduped/superseded before), and the emitted
//! [`ReductionLog`] is exactly the set of reductions the handoff itself
//! establishes: the standard lossless per-message passes (A7-A9, TR-2, TR-4,
//! TR-6, TR-10) plus one `TurnsCleared` per non-kept gap.

use std::collections::HashSet;

use super::summarize::SpanSummarizer;
use super::{
    count_roles, format_commas, hash_turns_range, make_id, project_messages, render_span_text,
    set_reduction_id, stub, MessageAddr, Reduction, ReductionKind, ReductionLog, ReductionPolicy,
    SidecarPtr,
};
use crate::Result;
use supercode_interchange::{ChatMessage, Role};

/// One parsed `--keep` token.
#[derive(Debug, Clone, PartialEq, Eq)]
enum KeepToken {
    /// A single canonical message address (e.g. `"12"`).
    Addr(usize),
    /// An inclusive address range (e.g. `"5-9"`).
    Range(usize, usize),
    /// A file path (or substring of one) — every message that reads or
    /// writes a matching path is kept, along with its paired tool call/result.
    Path(String),
}

/// Parse one `--keep` token: a bare integer is an address, `A-B` (both sides
/// integers) is an inclusive range, anything else is a path/substring.
fn parse_token(tok: &str) -> KeepToken {
    let t = tok.trim();
    if let Ok(n) = t.parse::<usize>() {
        return KeepToken::Addr(n);
    }
    if let Some((a, b)) = t.split_once('-') {
        if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
            return KeepToken::Range(a.min(b), a.max(b));
        }
    }
    KeepToken::Path(t.to_string())
}

/// Does `call`'s parsed arguments reference `path` (via a `path` or
/// `file_path` field, substring match)? Covers this crate's own builtins
/// (`path`) and Claude Code's native `Write`/`Read` (`file_path`).
fn call_touches_path(call: &supercode_interchange::ToolCall, path: &str) -> bool {
    let Ok(args) = call.function.parsed_arguments() else {
        return false;
    };
    args.get("path")
        .or_else(|| args.get("file_path"))
        .and_then(|v| v.as_str())
        .is_some_and(|v| v.contains(path))
}

/// The index of the `Role::Tool` result paired (by `tool_call_id`) to `call`,
/// searched forward from `after` — mirrors [`super::detect_tool_inputs`]'s
/// own pairing direction.
fn paired_result_index(msgs: &[ChatMessage], after: usize, call_id: &str) -> Option<usize> {
    msgs[after + 1..]
        .iter()
        .position(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call_id))
        .map(|off| after + 1 + off)
}

/// The index of the `Role::Assistant` message whose `tool_calls` contains
/// `call_id`, searched backward from (and excluding) `before` — mirrors
/// [`super::detect_reads`]'s own pairing direction.
fn paired_call_index(msgs: &[ChatMessage], before: usize, call_id: &str) -> Option<usize> {
    msgs[..before]
        .iter()
        .rposition(|m| m.role == Role::Assistant && m.tool_calls().iter().any(|c| c.id == call_id))
}

/// Resolve the full keep-set (SPEC.md TR-9 dev/01/dev/03): user-named
/// addresses/paths (`tokens`) PLUS the deterministic always-keeps — the
/// leading system prompt (if any) and the last `keep_last` messages — closed
/// under tool-call/tool-result pairing so a kept call never dangles without
/// its result (or vice versa). Pure function of `msgs` alone; canonical
/// (sidecar) indices throughout.
pub fn resolve_keep_indices(
    msgs: &[ChatMessage],
    tokens: &[String],
    keep_last: usize,
) -> Vec<usize> {
    let len = msgs.len();
    let mut keep: HashSet<usize> = HashSet::new();

    // Always-keep #1: the leading system prompt (SPEC.md dev/03).
    let mut i = 0;
    while i < len && msgs[i].role == Role::System {
        keep.insert(i);
        i += 1;
    }

    // Always-keep #2: the last `keep_last` messages (SPEC.md dev/03),
    // extended backward past a lone leading tool result (never start the
    // kept tail mid-call, mirroring `compute_clear_range`'s own tool-
    // boundary guard for the surviving window).
    if keep_last > 0 && len > 0 {
        let mut start = len.saturating_sub(keep_last);
        while start > 0 && msgs[start].role == Role::Tool {
            start -= 1;
        }
        for idx in start..len {
            keep.insert(idx);
        }
    }

    // User-named tokens.
    for tok in tokens {
        match parse_token(tok) {
            KeepToken::Addr(n) => {
                if n < len {
                    keep.insert(n);
                }
            }
            KeepToken::Range(a, b) => {
                // Clamp the upper bound BEFORE iterating: an unclamped `b`
                // (e.g. `--keep 5-99999999999`) would otherwise loop billions
                // of times just to have every out-of-range `n` immediately
                // discarded by an `n < len` check inside the loop — a
                // plausible hang on any real session. Clamping first bounds
                // the iteration count by `len` itself, with identical
                // behavior for every in-range value (and a no-op when
                // `len == 0` or `a >= len`, matching the old unclamped loop's
                // behavior exactly in both cases).
                if len == 0 {
                    continue;
                }
                let b = b.min(len - 1);
                if a <= b {
                    for n in a..=b {
                        keep.insert(n);
                    }
                }
            }
            KeepToken::Path(p) => {
                for (idx, m) in msgs.iter().enumerate() {
                    if m.role != Role::Assistant {
                        continue;
                    }
                    for call in m.tool_calls() {
                        if call_touches_path(call, &p) {
                            keep.insert(idx);
                            if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
                                keep.insert(ridx);
                            }
                        }
                    }
                }
            }
        }
    }

    // Tool-call/tool-result pairing closure: one pass suffices (pairing is
    // 1:1, no transitive chains).
    let snapshot: Vec<usize> = keep.iter().copied().collect();
    for idx in snapshot {
        match msgs[idx].role {
            Role::Tool => {
                if let Some(cid) = msgs[idx].tool_call_id.clone() {
                    if let Some(aidx) = paired_call_index(msgs, idx, &cid) {
                        keep.insert(aidx);
                    }
                }
            }
            Role::Assistant => {
                for call in msgs[idx].tool_calls().iter().cloned() {
                    if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
                        keep.insert(ridx);
                    }
                }
            }
            _ => {}
        }
    }

    let mut out: Vec<usize> = keep.into_iter().collect();
    out.sort_unstable();
    out
}

/// Maximal contiguous runs of `0..len` NOT in `keep` — the spanning-clear
/// candidates, one [`ReductionKind::TurnsCleared`] per run.
fn compute_gaps(len: usize, keep: &HashSet<usize>) -> Vec<(usize, usize)> {
    let mut gaps = Vec::new();
    let mut i = 0;
    while i < len {
        if keep.contains(&i) {
            i += 1;
            continue;
        }
        let start = i;
        while i < len && !keep.contains(&i) {
            i += 1;
        }
        gaps.push((start, i - 1));
    }
    gaps
}

/// Where the objective paragraph came from — [`build_handoff`]'s dev/05
/// contract: user text always wins when given; otherwise, drafting is
/// attempted only when `draft_objective` is on AND a summarizer is injected,
/// and any failure (no summarizer, an `Err`, or a blank result) falls back to
/// the plain banner with no objective line at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectiveSource {
    /// `--objective "<text>"` was given verbatim (trimmed).
    UserSupplied,
    /// Drafted via the injected [`SpanSummarizer`] (TR-7 plumbing reuse).
    Drafted,
    /// No objective at all: `--draft-objective` off (the default), no
    /// summarizer injected, or the side-call errored/returned blank.
    Fallback,
}

/// TR-9's reuse of TR-7's injectable side-call plumbing (SPEC.md TR-9 dev/05):
/// user-supplied text always wins outright (no side-call attempted); when
/// none is given AND `draft_objective` is on AND a summarizer is injected,
/// draft one paragraph from a rendering of the whole session — any error or
/// blank result falls back to no objective (the plain banner), exactly like
/// [`super::prepare_cleared_turns_summary`]'s own failure-fallback contract.
/// Off by default: a caller must both pass `draft_objective: true` AND inject
/// a summarizer for drafting to ever be attempted at all.
fn resolve_objective(
    msgs: &[ChatMessage],
    objective: Option<&str>,
    draft_objective: bool,
    summarizer: Option<&dyn SpanSummarizer>,
) -> (Option<String>, ObjectiveSource) {
    if let Some(text) = objective {
        let t = text.trim();
        if !t.is_empty() {
            return (Some(t.to_string()), ObjectiveSource::UserSupplied);
        }
    }
    if draft_objective {
        if let Some(s) = summarizer {
            let span_text = render_span_text(msgs);
            if let Ok(t) = s.summarize(&span_text) {
                let t = t.trim();
                if !t.is_empty() {
                    return (Some(t.to_string()), ObjectiveSource::Drafted);
                }
            }
        }
    }
    (None, ObjectiveSource::Fallback)
}

/// Render the handoff banner: a fixed honesty header naming every gap's
/// reduction id (so `expand_reduction("<id>")`/`/expand <id>` are spelled
/// out, mirroring TR-7's own placeholder banner), plus the objective
/// paragraph when one was resolved.
fn format_banner(objective: Option<&str>, gap_ids: &[String], total_cleared: usize) -> String {
    let mut s = String::from("=== HANDOFF ===\n");
    if gap_ids.is_empty() {
        s.push_str(
            "This is a curated continuation of a prior session. Nothing was cleared — the \
             full session is already in view.",
        );
    } else {
        s.push_str(&format!(
            "This is a curated continuation of a prior session. {} earlier turn(s) were \
             cleared into {} spanning reduction(s) and remain fully recoverable — call \
             expand_reduction(\"<id>\") (or `/expand <id>`) for any of: {}. The full session \
             is unchanged in its sidecar.",
            format_commas(total_cleared),
            gap_ids.len(),
            gap_ids.join(", "),
        ));
    }
    if let Some(obj) = objective {
        s.push_str("\n\nOBJECTIVE: ");
        s.push_str(obj);
    }
    s
}

/// The complete output of [`build_handoff`].
#[derive(Debug, Clone)]
pub struct HandoffResult {
    /// The projected view: banner+objective folded into the leading
    /// system-role message, followed by every kept message (in original
    /// order, with the standard lossless per-message reductions already
    /// applied) and one placeholder per cleared gap.
    pub view: Vec<ChatMessage>,
    /// The fresh reduction log this handoff establishes (SPEC.md: "a named
    /// `ReductionLog` snapshot") — the standard per-message passes plus one
    /// `TurnsCleared` per gap. Never contains the banner/objective text.
    pub log: ReductionLog,
    /// Canonical (sidecar) indices in the keep-set, sorted ascending.
    pub kept_indices: Vec<usize>,
    /// The ids of the `TurnsCleared` reductions minted, one per gap, in
    /// chronological (address) order.
    pub gap_ids: Vec<String>,
    /// Where the objective paragraph came from.
    pub objective_source: ObjectiveSource,
    /// The resolved objective text, if any (also folded into `view[0]`, kept
    /// here separately for callers/tests that want it without re-parsing).
    pub objective_text: Option<String>,
}

/// The base projection policy [`build_handoff`] runs its first (standard,
/// per-message) pass under: every lossless SPEC.md default lever (A7-A9,
/// TR-2, TR-4, TR-6, TR-10) stays on, but A10 (`clear_turns_older_than`) is
/// forced off — the handoff's own multi-gap spanning clear (below) replaces
/// it entirely, so the single-range auto-compactor pass must never also fire
/// in the same call.
fn base_policy() -> ReductionPolicy {
    ReductionPolicy {
        clear_turns_older_than: None,
        ..ReductionPolicy::default()
    }
}

/// Build a handoff projection over `msgs` (the canonical, full-fidelity
/// session messages — e.g. `sidecar.messages`). SPEC.md TR-9: produces a new
/// PROJECTED continuation — banner + objective + curated keep-set, with
/// every other turn covered by one `TurnsCleared` span per gap between kept
/// turns — over the SAME sidecar (this function never touches a sidecar or
/// disk at all; persisting `HandoffResult::log`/`view` is the caller's job,
/// e.g. `supercode handoff`'s CLI handler).
///
/// `keep_tokens` are `--keep` selectors (addresses, address ranges, or file
/// paths/substrings); `keep_last` is the deterministic always-keep tail
/// length (SPEC.md dev/03, alongside the always-kept leading system prompt).
/// `objective`/`draft_objective`/`summarizer` implement SPEC.md dev/05 (see
/// `resolve_objective`).
pub fn build_handoff(
    msgs: &[ChatMessage],
    keep_tokens: &[String],
    keep_last: usize,
    objective: Option<&str>,
    draft_objective: bool,
    summarizer: Option<&dyn SpanSummarizer>,
) -> Result<HandoffResult> {
    let kept_indices = resolve_keep_indices(msgs, keep_tokens, keep_last);
    let keep: HashSet<usize> = kept_indices.iter().copied().collect();

    // Step 1: the standard lossless per-message passes over the WHOLE
    // canonical session, fresh (SPEC.md: "one projection event") — A10
    // stays off; the multi-gap spanning clear below is this function's own
    // A10 analog. `view`/`log` stay index-parallel to `msgs` here (no
    // cardinality-changing pass has run yet), which every splice below
    // depends on.
    let (mut view, mut log) = project_messages(msgs, &base_policy(), &ReductionLog::default());
    debug_assert_eq!(view.len(), msgs.len());

    let gaps = compute_gaps(msgs.len(), &keep);
    let mut new_ids: Vec<String> = Vec::with_capacity(gaps.len());
    let mut ordinal = log.reductions.len();

    // Mint one `TurnsCleared` per gap, splicing from the LAST gap to the
    // FIRST so an earlier splice's cardinality shrink never invalidates a
    // later (still `msgs`-indexed) one.
    // ordinal is offset from log.reductions.len() (not 0-based), so a plain
    // enumerate() over gaps would produce the wrong ids — explicit counter is correct here.
    #[allow(clippy::explicit_counter_loop)]
    for &(first, last) in gaps.iter().rev() {
        let range = &msgs[first..=last];
        let (hash, range_bytes) = hash_turns_range(range)?;
        let (user, assistant, tool) = count_roles(range);
        let id = make_id(ordinal, &hash);
        ordinal += 1;
        let summary = format!(
            "turns {first}..{} cleared by handoff ({} messages: {user} user, {assistant} \
             assistant, {tool} tool; {}B) — full turns in session sidecar",
            last + 1,
            format_commas(range.len()),
            format_commas(range_bytes),
        );
        let placeholder = stub::format(stub::Kind::TurnsCleared, &id, &summary);

        // Any per-message reduction step 1 minted inside this gap is now
        // subsumed by the single spanning placeholder (mirrors the existing
        // A10 pass's own subsumption rule) — `invert` restores the WHOLE
        // range straight from the sidecar regardless.
        log.reductions
            .retain(|r| !(r.ptr.addr.index >= first && r.ptr.addr.index <= last));

        let reduction = Reduction {
            id: id.clone(),
            kind: ReductionKind::TurnsCleared {
                first,
                last,
                summary: None,
            },
            ptr: SidecarPtr {
                addr: MessageAddr {
                    index: first,
                    role: range[0].role,
                },
                span: None,
                content_hash: hash,
            },
            placeholder,
        };

        let mut stub_msg = ChatMessage::system(reduction.placeholder.clone());
        set_reduction_id(&mut stub_msg, &reduction.id);
        view.splice(first..=last, std::iter::once(stub_msg));

        log.reductions.push(reduction);
        new_ids.push(id);
    }
    new_ids.reverse(); // restore chronological (address) order

    let total_cleared: usize = gaps.iter().map(|&(f, l)| l - f + 1).sum();
    let (objective_text, objective_source) =
        resolve_objective(msgs, objective, draft_objective, summarizer);
    let banner = format_banner(objective_text.as_deref(), &new_ids, total_cleared);

    // Banner/objective: view-only, folded into the leading system-role
    // message (merged if the CANONICAL session genuinely starts with one —
    // checked against `msgs`, never `view`, since `view[0]` could otherwise
    // be a `TurnsCleared` gap placeholder that also happens to be
    // system-role and must never be contaminated with banner text) — NEVER
    // recorded as a `Reduction`, so it can never reach the sidecar or an
    // export (both rebuild only from sidecar bytes, never from this view).
    // Index 0 being genuinely system-role in `msgs` also means index 0 is
    // unconditionally in the keep-set (the always-keep loop above), so
    // `view[0]` really is that same, unreduced message here.
    if msgs.first().map(|m| m.role) == Some(Role::System) {
        let existing = view[0].content.clone().unwrap_or_default();
        view[0].content = Some(if existing.is_empty() {
            banner
        } else {
            format!("{banner}\n\n{existing}")
        });
    } else {
        view.insert(0, ChatMessage::system(banner));
    }

    Ok(HandoffResult {
        view,
        log,
        kept_indices,
        gap_ids: new_ids,
        objective_source,
        objective_text,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn msg(role: Role, content: &str) -> ChatMessage {
        ChatMessage {
            role,
            content: Some(content.to_string()),
            content_parts: None,
            tool_calls: None,
            tool_call_id: None,
            name: None,
            metadata: Default::default(),
        }
    }

    fn plain_session(n: usize) -> Vec<ChatMessage> {
        let mut out = vec![msg(Role::System, "you are a helpful agent")];
        for i in 0..n {
            out.push(msg(Role::User, &format!("user turn {i}")));
            out.push(msg(Role::Assistant, &format!("assistant reply {i}")));
        }
        out
    }

    #[test]
    fn keep_set_always_includes_system_prompt_and_last_k() {
        let msgs = plain_session(20);
        let keep = resolve_keep_indices(&msgs, &[], 4);
        assert!(keep.contains(&0), "system prompt must always be kept");
        let len = msgs.len();
        for idx in (len - 4)..len {
            assert!(keep.contains(&idx), "last-K tail index {idx} must be kept");
        }
    }

    /// A huge upper bound (`--keep 5-99999999999`) must return promptly
    /// (the upper bound is clamped to `len - 1` BEFORE the loop runs, never
    /// iterated billions of times) and keep only the in-range indices —
    /// identical behavior to a properly in-range upper bound.
    #[test]
    fn keep_range_with_huge_upper_bound_returns_promptly_and_clamps() {
        let msgs = plain_session(10); // 21 messages: index 0 is the system
                                      // prompt (always kept), 1..=20 the turns.
        let len = msgs.len();
        let keep = resolve_keep_indices(&msgs, &["5-99999999999".to_string()], 0);
        let keep_set: HashSet<usize> = keep.iter().copied().collect();
        for i in 5..len {
            assert!(keep_set.contains(&i), "in-range index {i} must be kept");
        }
        // Below the range (excluding index 0, the always-kept system prompt):
        // must NOT be kept — proves the huge upper bound didn't somehow keep
        // everything, and that clamping doesn't disturb the lower bound.
        for i in 1..5 {
            assert!(
                !keep_set.contains(&i),
                "index {i} is below the range and must not be kept"
            );
        }
        // Nothing beyond `len - 1` was ever inserted (would be unreachable
        // anyway, but pins down that clamping didn't keep out-of-range junk).
        assert!(keep.iter().all(|&i| i < len));

        // `a` itself out of range (still parsed as `Range(a, huge)` since
        // both sides are integers) must clamp to an empty, no-op range
        // rather than panicking on `a..=b` with `a > b` — the resulting
        // keep-set is exactly the deterministic always-keeps (just the
        // system prompt here, since `keep_last` is 0), nothing the token
        // itself contributed.
        let baseline = resolve_keep_indices(&msgs, &[], 0);
        let keep_oob = resolve_keep_indices(&msgs, &["99999-99999999999".to_string()], 0);
        assert_eq!(
            keep_oob, baseline,
            "an entirely out-of-range `--keep` token must contribute nothing"
        );
    }

    #[test]
    fn gaps_cover_everything_not_kept() {
        let msgs = plain_session(10);
        let keep = resolve_keep_indices(&msgs, &["5".to_string()], 2);
        let keep_set: HashSet<usize> = keep.iter().copied().collect();
        let gaps = compute_gaps(msgs.len(), &keep_set);
        let mut covered: HashSet<usize> = HashSet::new();
        for (f, l) in gaps {
            for i in f..=l {
                assert!(
                    !keep_set.contains(&i),
                    "gap must never include a kept index"
                );
                covered.insert(i);
            }
        }
        for i in 0..msgs.len() {
            assert!(
                keep_set.contains(&i) || covered.contains(&i),
                "index {i} neither kept nor covered by a gap"
            );
        }
    }

    #[test]
    fn build_handoff_yields_small_projected_view_with_banner_and_gap_stubs() {
        let msgs = plain_session(200);
        let result = build_handoff(
            &msgs,
            &["3".to_string()],
            4,
            Some("finish the refactor"),
            false,
            None,
        )
        .unwrap();
        assert!(result.view[0]
            .content
            .as_deref()
            .unwrap()
            .contains("=== HANDOFF ==="));
        assert!(result.view[0]
            .content
            .as_deref()
            .unwrap()
            .contains("OBJECTIVE: finish the refactor"));
        assert!(!result.gap_ids.is_empty());
        assert_eq!(result.objective_source, ObjectiveSource::UserSupplied);
        // Every gap id must be a real reduction in the log.
        for id in &result.gap_ids {
            assert!(result.log.reductions.iter().any(|r| &r.id == id));
        }
    }

    struct FailingSummarizer;
    impl SpanSummarizer for FailingSummarizer {
        fn summarize(&self, _span_text: &str) -> crate::Result<String> {
            Err(crate::ReductionError::new("boom"))
        }
        fn model_id(&self) -> &str {
            "failing-test-model"
        }
    }

    #[test]
    fn objective_draft_off_by_default_and_falls_back_on_summarizer_failure() {
        let msgs = plain_session(20);
        // draft_objective off, no summarizer: no objective, plain banner.
        let (obj, src) = resolve_objective(&msgs, None, false, None);
        assert_eq!(obj, None);
        assert_eq!(src, ObjectiveSource::Fallback);

        // draft_objective on but the injected summarizer always errors:
        // falls back identically.
        let (obj, src) = resolve_objective(&msgs, None, true, Some(&FailingSummarizer));
        assert_eq!(obj, None);
        assert_eq!(src, ObjectiveSource::Fallback);

        // user text always wins outright, even with drafting on.
        let (obj, src) =
            resolve_objective(&msgs, Some("do the thing"), true, Some(&FailingSummarizer));
        assert_eq!(obj.as_deref(), Some("do the thing"));
        assert_eq!(src, ObjectiveSource::UserSupplied);
    }
}