Skip to main content

supercode_reduce/engine/
handoff.rs

1//! TR-9 (T24): `/handoff` as extreme projection — "Amp retired
2//! summarization-compaction for /handoff: spawn a new thread seeded with a
3//! drafted objective + curated relevant artifacts, old thread untouched."
4//! (TOKEN-REDUCTION-TECHNIQUES.md@ad3f2a4 §T24). In supercode a handoff is
5//! NOT a new session lineage — it is a named [`ReductionLog`] snapshot over
6//! the SAME sidecar: the projected view collapses to (1) a handoff banner,
7//! (2) an objective paragraph, and (3) a curated keep-set, with EVERY OTHER
8//! turn covered by one [`ReductionKind::TurnsCleared`] span per contiguous
9//! gap between kept turns.
10//!
11//! # Design: reusing `TurnsCleared`, not inventing a new kind
12//!
13//! A handoff keep-set is generally scattered (system prompt + some
14//! user-named early turns + the last K turns), so the "everything else"
15//! complement is generally MULTIPLE disjoint ranges, not one. Rather than a
16//! dedicated `HandoffCleared` reduction kind, this reuses
17//! [`ReductionKind::TurnsCleared`] verbatim — one record per gap — which
18//! required exactly one change to [`super::project_messages`] itself: its
19//! reapplication path used to assume "at most one `TurnsCleared` reduction,
20//! ever" (true for the auto-compactor, `Agent::maybe_compact`); it now
21//! reapplies EVERY existing `TurnsCleared` record it finds, which is a
22//! strict, backward-compatible generalization (a log with zero or one such
23//! record behaves identically to before). `invert`/`verify_log`/
24//! `expand_reduction`/`sidecar_search` already treated `log.reductions` as an
25//! unordered bag keyed by `id`/address — those needed NO changes at all.
26//!
27//! # Banner/objective are view-only, never in the sidecar
28//!
29//! The banner+objective text is never a [`Reduction`] and never recorded in
30//! the [`ReductionLog`] — it is folded into the projected VIEW's leading
31//! system-role message only (merged into an existing leading system message
32//! if the canonical session has one, else inserted as a new one), exactly
33//! like TR-7's [`super::SpanSummary`] audit metadata: view-layer only,
34//! `invert`/`verify_log` never look at it, and it structurally cannot reach
35//! `supercode-core`'s session export adapters (which rebuild
36//! **only** from the sidecar, never from a projected view).
37//!
38//! # Fresh projection, not an incremental one
39//!
40//! [`build_handoff`] deliberately builds its handoff view from a FRESH
41//! projection (`ReductionLog::default()`) over the canonical session rather
42//! than reapplying whatever reduction log the session already had — a
43//! handoff is "one projection event" (SPEC.md C7's own framing), so kept
44//! turns show their full, untouched content (never stacked on top of
45//! whatever had been truncated/deduped/superseded before), and the emitted
46//! [`ReductionLog`] is exactly the set of reductions the handoff itself
47//! establishes: the standard lossless per-message passes (A7-A9, TR-2, TR-4,
48//! TR-6, TR-10) plus one `TurnsCleared` per non-kept gap.
49
50use std::collections::HashSet;
51
52use super::summarize::SpanSummarizer;
53use super::{
54    count_roles, format_commas, hash_turns_range, make_id, project_messages, render_span_text,
55    set_reduction_id, stub, MessageAddr, Reduction, ReductionKind, ReductionLog, ReductionPolicy,
56    SidecarPtr,
57};
58use crate::Result;
59use supercode_interchange::{ChatMessage, Role};
60
61/// One parsed `--keep` token.
62#[derive(Debug, Clone, PartialEq, Eq)]
63enum KeepToken {
64    /// A single canonical message address (e.g. `"12"`).
65    Addr(usize),
66    /// An inclusive address range (e.g. `"5-9"`).
67    Range(usize, usize),
68    /// A file path (or substring of one) — every message that reads or
69    /// writes a matching path is kept, along with its paired tool call/result.
70    Path(String),
71}
72
73/// Parse one `--keep` token: a bare integer is an address, `A-B` (both sides
74/// integers) is an inclusive range, anything else is a path/substring.
75fn parse_token(tok: &str) -> KeepToken {
76    let t = tok.trim();
77    if let Ok(n) = t.parse::<usize>() {
78        return KeepToken::Addr(n);
79    }
80    if let Some((a, b)) = t.split_once('-') {
81        if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
82            return KeepToken::Range(a.min(b), a.max(b));
83        }
84    }
85    KeepToken::Path(t.to_string())
86}
87
88/// Does `call`'s parsed arguments reference `path` (via a `path` or
89/// `file_path` field, substring match)? Covers this crate's own builtins
90/// (`path`) and Claude Code's native `Write`/`Read` (`file_path`).
91fn call_touches_path(call: &supercode_interchange::ToolCall, path: &str) -> bool {
92    let Ok(args) = call.function.parsed_arguments() else {
93        return false;
94    };
95    args.get("path")
96        .or_else(|| args.get("file_path"))
97        .and_then(|v| v.as_str())
98        .is_some_and(|v| v.contains(path))
99}
100
101/// The index of the `Role::Tool` result paired (by `tool_call_id`) to `call`,
102/// searched forward from `after` — mirrors [`super::detect_tool_inputs`]'s
103/// own pairing direction.
104fn paired_result_index(msgs: &[ChatMessage], after: usize, call_id: &str) -> Option<usize> {
105    msgs[after + 1..]
106        .iter()
107        .position(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call_id))
108        .map(|off| after + 1 + off)
109}
110
111/// The index of the `Role::Assistant` message whose `tool_calls` contains
112/// `call_id`, searched backward from (and excluding) `before` — mirrors
113/// [`super::detect_reads`]'s own pairing direction.
114fn paired_call_index(msgs: &[ChatMessage], before: usize, call_id: &str) -> Option<usize> {
115    msgs[..before]
116        .iter()
117        .rposition(|m| m.role == Role::Assistant && m.tool_calls().iter().any(|c| c.id == call_id))
118}
119
120/// Resolve the full keep-set (SPEC.md TR-9 dev/01/dev/03): user-named
121/// addresses/paths (`tokens`) PLUS the deterministic always-keeps — the
122/// leading system prompt (if any) and the last `keep_last` messages — closed
123/// under tool-call/tool-result pairing so a kept call never dangles without
124/// its result (or vice versa). Pure function of `msgs` alone; canonical
125/// (sidecar) indices throughout.
126pub fn resolve_keep_indices(
127    msgs: &[ChatMessage],
128    tokens: &[String],
129    keep_last: usize,
130) -> Vec<usize> {
131    let len = msgs.len();
132    let mut keep: HashSet<usize> = HashSet::new();
133
134    // Always-keep #1: the leading system prompt (SPEC.md dev/03).
135    let mut i = 0;
136    while i < len && msgs[i].role == Role::System {
137        keep.insert(i);
138        i += 1;
139    }
140
141    // Always-keep #2: the last `keep_last` messages (SPEC.md dev/03),
142    // extended backward past a lone leading tool result (never start the
143    // kept tail mid-call, mirroring `compute_clear_range`'s own tool-
144    // boundary guard for the surviving window).
145    if keep_last > 0 && len > 0 {
146        let mut start = len.saturating_sub(keep_last);
147        while start > 0 && msgs[start].role == Role::Tool {
148            start -= 1;
149        }
150        for idx in start..len {
151            keep.insert(idx);
152        }
153    }
154
155    // User-named tokens.
156    for tok in tokens {
157        match parse_token(tok) {
158            KeepToken::Addr(n) => {
159                if n < len {
160                    keep.insert(n);
161                }
162            }
163            KeepToken::Range(a, b) => {
164                // Clamp the upper bound BEFORE iterating: an unclamped `b`
165                // (e.g. `--keep 5-99999999999`) would otherwise loop billions
166                // of times just to have every out-of-range `n` immediately
167                // discarded by an `n < len` check inside the loop — a
168                // plausible hang on any real session. Clamping first bounds
169                // the iteration count by `len` itself, with identical
170                // behavior for every in-range value (and a no-op when
171                // `len == 0` or `a >= len`, matching the old unclamped loop's
172                // behavior exactly in both cases).
173                if len == 0 {
174                    continue;
175                }
176                let b = b.min(len - 1);
177                if a <= b {
178                    for n in a..=b {
179                        keep.insert(n);
180                    }
181                }
182            }
183            KeepToken::Path(p) => {
184                for (idx, m) in msgs.iter().enumerate() {
185                    if m.role != Role::Assistant {
186                        continue;
187                    }
188                    for call in m.tool_calls() {
189                        if call_touches_path(call, &p) {
190                            keep.insert(idx);
191                            if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
192                                keep.insert(ridx);
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200
201    // Tool-call/tool-result pairing closure: one pass suffices (pairing is
202    // 1:1, no transitive chains).
203    let snapshot: Vec<usize> = keep.iter().copied().collect();
204    for idx in snapshot {
205        match msgs[idx].role {
206            Role::Tool => {
207                if let Some(cid) = msgs[idx].tool_call_id.clone() {
208                    if let Some(aidx) = paired_call_index(msgs, idx, &cid) {
209                        keep.insert(aidx);
210                    }
211                }
212            }
213            Role::Assistant => {
214                for call in msgs[idx].tool_calls().iter().cloned() {
215                    if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
216                        keep.insert(ridx);
217                    }
218                }
219            }
220            _ => {}
221        }
222    }
223
224    let mut out: Vec<usize> = keep.into_iter().collect();
225    out.sort_unstable();
226    out
227}
228
229/// Maximal contiguous runs of `0..len` NOT in `keep` — the spanning-clear
230/// candidates, one [`ReductionKind::TurnsCleared`] per run.
231fn compute_gaps(len: usize, keep: &HashSet<usize>) -> Vec<(usize, usize)> {
232    let mut gaps = Vec::new();
233    let mut i = 0;
234    while i < len {
235        if keep.contains(&i) {
236            i += 1;
237            continue;
238        }
239        let start = i;
240        while i < len && !keep.contains(&i) {
241            i += 1;
242        }
243        gaps.push((start, i - 1));
244    }
245    gaps
246}
247
248/// Where the objective paragraph came from — [`build_handoff`]'s dev/05
249/// contract: user text always wins when given; otherwise, drafting is
250/// attempted only when `draft_objective` is on AND a summarizer is injected,
251/// and any failure (no summarizer, an `Err`, or a blank result) falls back to
252/// the plain banner with no objective line at all.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum ObjectiveSource {
255    /// `--objective "<text>"` was given verbatim (trimmed).
256    UserSupplied,
257    /// Drafted via the injected [`SpanSummarizer`] (TR-7 plumbing reuse).
258    Drafted,
259    /// No objective at all: `--draft-objective` off (the default), no
260    /// summarizer injected, or the side-call errored/returned blank.
261    Fallback,
262}
263
264/// TR-9's reuse of TR-7's injectable side-call plumbing (SPEC.md TR-9 dev/05):
265/// user-supplied text always wins outright (no side-call attempted); when
266/// none is given AND `draft_objective` is on AND a summarizer is injected,
267/// draft one paragraph from a rendering of the whole session — any error or
268/// blank result falls back to no objective (the plain banner), exactly like
269/// [`super::prepare_cleared_turns_summary`]'s own failure-fallback contract.
270/// Off by default: a caller must both pass `draft_objective: true` AND inject
271/// a summarizer for drafting to ever be attempted at all.
272fn resolve_objective(
273    msgs: &[ChatMessage],
274    objective: Option<&str>,
275    draft_objective: bool,
276    summarizer: Option<&dyn SpanSummarizer>,
277) -> (Option<String>, ObjectiveSource) {
278    if let Some(text) = objective {
279        let t = text.trim();
280        if !t.is_empty() {
281            return (Some(t.to_string()), ObjectiveSource::UserSupplied);
282        }
283    }
284    if draft_objective {
285        if let Some(s) = summarizer {
286            let span_text = render_span_text(msgs);
287            if let Ok(t) = s.summarize(&span_text) {
288                let t = t.trim();
289                if !t.is_empty() {
290                    return (Some(t.to_string()), ObjectiveSource::Drafted);
291                }
292            }
293        }
294    }
295    (None, ObjectiveSource::Fallback)
296}
297
298/// Render the handoff banner: a fixed honesty header naming every gap's
299/// reduction id (so `expand_reduction("<id>")`/`/expand <id>` are spelled
300/// out, mirroring TR-7's own placeholder banner), plus the objective
301/// paragraph when one was resolved.
302fn format_banner(objective: Option<&str>, gap_ids: &[String], total_cleared: usize) -> String {
303    let mut s = String::from("=== HANDOFF ===\n");
304    if gap_ids.is_empty() {
305        s.push_str(
306            "This is a curated continuation of a prior session. Nothing was cleared — the \
307             full session is already in view.",
308        );
309    } else {
310        s.push_str(&format!(
311            "This is a curated continuation of a prior session. {} earlier turn(s) were \
312             cleared into {} spanning reduction(s) and remain fully recoverable — call \
313             expand_reduction(\"<id>\") (or `/expand <id>`) for any of: {}. The full session \
314             is unchanged in its sidecar.",
315            format_commas(total_cleared),
316            gap_ids.len(),
317            gap_ids.join(", "),
318        ));
319    }
320    if let Some(obj) = objective {
321        s.push_str("\n\nOBJECTIVE: ");
322        s.push_str(obj);
323    }
324    s
325}
326
327/// The complete output of [`build_handoff`].
328#[derive(Debug, Clone)]
329pub struct HandoffResult {
330    /// The projected view: banner+objective folded into the leading
331    /// system-role message, followed by every kept message (in original
332    /// order, with the standard lossless per-message reductions already
333    /// applied) and one placeholder per cleared gap.
334    pub view: Vec<ChatMessage>,
335    /// The fresh reduction log this handoff establishes (SPEC.md: "a named
336    /// `ReductionLog` snapshot") — the standard per-message passes plus one
337    /// `TurnsCleared` per gap. Never contains the banner/objective text.
338    pub log: ReductionLog,
339    /// Canonical (sidecar) indices in the keep-set, sorted ascending.
340    pub kept_indices: Vec<usize>,
341    /// The ids of the `TurnsCleared` reductions minted, one per gap, in
342    /// chronological (address) order.
343    pub gap_ids: Vec<String>,
344    /// Where the objective paragraph came from.
345    pub objective_source: ObjectiveSource,
346    /// The resolved objective text, if any (also folded into `view[0]`, kept
347    /// here separately for callers/tests that want it without re-parsing).
348    pub objective_text: Option<String>,
349}
350
351/// The base projection policy [`build_handoff`] runs its first (standard,
352/// per-message) pass under: every lossless SPEC.md default lever (A7-A9,
353/// TR-2, TR-4, TR-6, TR-10) stays on, but A10 (`clear_turns_older_than`) is
354/// forced off — the handoff's own multi-gap spanning clear (below) replaces
355/// it entirely, so the single-range auto-compactor pass must never also fire
356/// in the same call.
357fn base_policy() -> ReductionPolicy {
358    ReductionPolicy {
359        clear_turns_older_than: None,
360        ..ReductionPolicy::default()
361    }
362}
363
364/// Build a handoff projection over `msgs` (the canonical, full-fidelity
365/// session messages — e.g. `sidecar.messages`). SPEC.md TR-9: produces a new
366/// PROJECTED continuation — banner + objective + curated keep-set, with
367/// every other turn covered by one `TurnsCleared` span per gap between kept
368/// turns — over the SAME sidecar (this function never touches a sidecar or
369/// disk at all; persisting `HandoffResult::log`/`view` is the caller's job,
370/// e.g. `supercode handoff`'s CLI handler).
371///
372/// `keep_tokens` are `--keep` selectors (addresses, address ranges, or file
373/// paths/substrings); `keep_last` is the deterministic always-keep tail
374/// length (SPEC.md dev/03, alongside the always-kept leading system prompt).
375/// `objective`/`draft_objective`/`summarizer` implement SPEC.md dev/05 (see
376/// `resolve_objective`).
377pub fn build_handoff(
378    msgs: &[ChatMessage],
379    keep_tokens: &[String],
380    keep_last: usize,
381    objective: Option<&str>,
382    draft_objective: bool,
383    summarizer: Option<&dyn SpanSummarizer>,
384) -> Result<HandoffResult> {
385    let kept_indices = resolve_keep_indices(msgs, keep_tokens, keep_last);
386    let keep: HashSet<usize> = kept_indices.iter().copied().collect();
387
388    // Step 1: the standard lossless per-message passes over the WHOLE
389    // canonical session, fresh (SPEC.md: "one projection event") — A10
390    // stays off; the multi-gap spanning clear below is this function's own
391    // A10 analog. `view`/`log` stay index-parallel to `msgs` here (no
392    // cardinality-changing pass has run yet), which every splice below
393    // depends on.
394    let (mut view, mut log) = project_messages(msgs, &base_policy(), &ReductionLog::default());
395    debug_assert_eq!(view.len(), msgs.len());
396
397    let gaps = compute_gaps(msgs.len(), &keep);
398    let mut new_ids: Vec<String> = Vec::with_capacity(gaps.len());
399    let mut ordinal = log.reductions.len();
400
401    // Mint one `TurnsCleared` per gap, splicing from the LAST gap to the
402    // FIRST so an earlier splice's cardinality shrink never invalidates a
403    // later (still `msgs`-indexed) one.
404    // ordinal is offset from log.reductions.len() (not 0-based), so a plain
405    // enumerate() over gaps would produce the wrong ids — explicit counter is correct here.
406    #[allow(clippy::explicit_counter_loop)]
407    for &(first, last) in gaps.iter().rev() {
408        let range = &msgs[first..=last];
409        let (hash, range_bytes) = hash_turns_range(range)?;
410        let (user, assistant, tool) = count_roles(range);
411        let id = make_id(ordinal, &hash);
412        ordinal += 1;
413        let summary = format!(
414            "turns {first}..{} cleared by handoff ({} messages: {user} user, {assistant} \
415             assistant, {tool} tool; {}B) — full turns in session sidecar",
416            last + 1,
417            format_commas(range.len()),
418            format_commas(range_bytes),
419        );
420        let placeholder = stub::format(stub::Kind::TurnsCleared, &id, &summary);
421
422        // Any per-message reduction step 1 minted inside this gap is now
423        // subsumed by the single spanning placeholder (mirrors the existing
424        // A10 pass's own subsumption rule) — `invert` restores the WHOLE
425        // range straight from the sidecar regardless.
426        log.reductions
427            .retain(|r| !(r.ptr.addr.index >= first && r.ptr.addr.index <= last));
428
429        let reduction = Reduction {
430            id: id.clone(),
431            kind: ReductionKind::TurnsCleared {
432                first,
433                last,
434                summary: None,
435            },
436            ptr: SidecarPtr {
437                addr: MessageAddr {
438                    index: first,
439                    role: range[0].role,
440                },
441                span: None,
442                content_hash: hash,
443            },
444            placeholder,
445        };
446
447        let mut stub_msg = ChatMessage::system(reduction.placeholder.clone());
448        set_reduction_id(&mut stub_msg, &reduction.id);
449        view.splice(first..=last, std::iter::once(stub_msg));
450
451        log.reductions.push(reduction);
452        new_ids.push(id);
453    }
454    new_ids.reverse(); // restore chronological (address) order
455
456    let total_cleared: usize = gaps.iter().map(|&(f, l)| l - f + 1).sum();
457    let (objective_text, objective_source) =
458        resolve_objective(msgs, objective, draft_objective, summarizer);
459    let banner = format_banner(objective_text.as_deref(), &new_ids, total_cleared);
460
461    // Banner/objective: view-only, folded into the leading system-role
462    // message (merged if the CANONICAL session genuinely starts with one —
463    // checked against `msgs`, never `view`, since `view[0]` could otherwise
464    // be a `TurnsCleared` gap placeholder that also happens to be
465    // system-role and must never be contaminated with banner text) — NEVER
466    // recorded as a `Reduction`, so it can never reach the sidecar or an
467    // export (both rebuild only from sidecar bytes, never from this view).
468    // Index 0 being genuinely system-role in `msgs` also means index 0 is
469    // unconditionally in the keep-set (the always-keep loop above), so
470    // `view[0]` really is that same, unreduced message here.
471    if msgs.first().map(|m| m.role) == Some(Role::System) {
472        let existing = view[0].content.clone().unwrap_or_default();
473        view[0].content = Some(if existing.is_empty() {
474            banner
475        } else {
476            format!("{banner}\n\n{existing}")
477        });
478    } else {
479        view.insert(0, ChatMessage::system(banner));
480    }
481
482    Ok(HandoffResult {
483        view,
484        log,
485        kept_indices,
486        gap_ids: new_ids,
487        objective_source,
488        objective_text,
489    })
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn msg(role: Role, content: &str) -> ChatMessage {
497        ChatMessage {
498            role,
499            content: Some(content.to_string()),
500            content_parts: None,
501            tool_calls: None,
502            tool_call_id: None,
503            name: None,
504            metadata: Default::default(),
505        }
506    }
507
508    fn plain_session(n: usize) -> Vec<ChatMessage> {
509        let mut out = vec![msg(Role::System, "you are a helpful agent")];
510        for i in 0..n {
511            out.push(msg(Role::User, &format!("user turn {i}")));
512            out.push(msg(Role::Assistant, &format!("assistant reply {i}")));
513        }
514        out
515    }
516
517    #[test]
518    fn keep_set_always_includes_system_prompt_and_last_k() {
519        let msgs = plain_session(20);
520        let keep = resolve_keep_indices(&msgs, &[], 4);
521        assert!(keep.contains(&0), "system prompt must always be kept");
522        let len = msgs.len();
523        for idx in (len - 4)..len {
524            assert!(keep.contains(&idx), "last-K tail index {idx} must be kept");
525        }
526    }
527
528    /// A huge upper bound (`--keep 5-99999999999`) must return promptly
529    /// (the upper bound is clamped to `len - 1` BEFORE the loop runs, never
530    /// iterated billions of times) and keep only the in-range indices —
531    /// identical behavior to a properly in-range upper bound.
532    #[test]
533    fn keep_range_with_huge_upper_bound_returns_promptly_and_clamps() {
534        let msgs = plain_session(10); // 21 messages: index 0 is the system
535                                      // prompt (always kept), 1..=20 the turns.
536        let len = msgs.len();
537        let keep = resolve_keep_indices(&msgs, &["5-99999999999".to_string()], 0);
538        let keep_set: HashSet<usize> = keep.iter().copied().collect();
539        for i in 5..len {
540            assert!(keep_set.contains(&i), "in-range index {i} must be kept");
541        }
542        // Below the range (excluding index 0, the always-kept system prompt):
543        // must NOT be kept — proves the huge upper bound didn't somehow keep
544        // everything, and that clamping doesn't disturb the lower bound.
545        for i in 1..5 {
546            assert!(
547                !keep_set.contains(&i),
548                "index {i} is below the range and must not be kept"
549            );
550        }
551        // Nothing beyond `len - 1` was ever inserted (would be unreachable
552        // anyway, but pins down that clamping didn't keep out-of-range junk).
553        assert!(keep.iter().all(|&i| i < len));
554
555        // `a` itself out of range (still parsed as `Range(a, huge)` since
556        // both sides are integers) must clamp to an empty, no-op range
557        // rather than panicking on `a..=b` with `a > b` — the resulting
558        // keep-set is exactly the deterministic always-keeps (just the
559        // system prompt here, since `keep_last` is 0), nothing the token
560        // itself contributed.
561        let baseline = resolve_keep_indices(&msgs, &[], 0);
562        let keep_oob = resolve_keep_indices(&msgs, &["99999-99999999999".to_string()], 0);
563        assert_eq!(
564            keep_oob, baseline,
565            "an entirely out-of-range `--keep` token must contribute nothing"
566        );
567    }
568
569    #[test]
570    fn gaps_cover_everything_not_kept() {
571        let msgs = plain_session(10);
572        let keep = resolve_keep_indices(&msgs, &["5".to_string()], 2);
573        let keep_set: HashSet<usize> = keep.iter().copied().collect();
574        let gaps = compute_gaps(msgs.len(), &keep_set);
575        let mut covered: HashSet<usize> = HashSet::new();
576        for (f, l) in gaps {
577            for i in f..=l {
578                assert!(
579                    !keep_set.contains(&i),
580                    "gap must never include a kept index"
581                );
582                covered.insert(i);
583            }
584        }
585        for i in 0..msgs.len() {
586            assert!(
587                keep_set.contains(&i) || covered.contains(&i),
588                "index {i} neither kept nor covered by a gap"
589            );
590        }
591    }
592
593    #[test]
594    fn build_handoff_yields_small_projected_view_with_banner_and_gap_stubs() {
595        let msgs = plain_session(200);
596        let result = build_handoff(
597            &msgs,
598            &["3".to_string()],
599            4,
600            Some("finish the refactor"),
601            false,
602            None,
603        )
604        .unwrap();
605        assert!(result.view[0]
606            .content
607            .as_deref()
608            .unwrap()
609            .contains("=== HANDOFF ==="));
610        assert!(result.view[0]
611            .content
612            .as_deref()
613            .unwrap()
614            .contains("OBJECTIVE: finish the refactor"));
615        assert!(!result.gap_ids.is_empty());
616        assert_eq!(result.objective_source, ObjectiveSource::UserSupplied);
617        // Every gap id must be a real reduction in the log.
618        for id in &result.gap_ids {
619            assert!(result.log.reductions.iter().any(|r| &r.id == id));
620        }
621    }
622
623    struct FailingSummarizer;
624    impl SpanSummarizer for FailingSummarizer {
625        fn summarize(&self, _span_text: &str) -> crate::Result<String> {
626            Err(crate::ReductionError::new("boom"))
627        }
628        fn model_id(&self) -> &str {
629            "failing-test-model"
630        }
631    }
632
633    #[test]
634    fn objective_draft_off_by_default_and_falls_back_on_summarizer_failure() {
635        let msgs = plain_session(20);
636        // draft_objective off, no summarizer: no objective, plain banner.
637        let (obj, src) = resolve_objective(&msgs, None, false, None);
638        assert_eq!(obj, None);
639        assert_eq!(src, ObjectiveSource::Fallback);
640
641        // draft_objective on but the injected summarizer always errors:
642        // falls back identically.
643        let (obj, src) = resolve_objective(&msgs, None, true, Some(&FailingSummarizer));
644        assert_eq!(obj, None);
645        assert_eq!(src, ObjectiveSource::Fallback);
646
647        // user text always wins outright, even with drafting on.
648        let (obj, src) =
649            resolve_objective(&msgs, Some("do the thing"), true, Some(&FailingSummarizer));
650        assert_eq!(obj.as_deref(), Some("do the thing"));
651        assert_eq!(src, ObjectiveSource::UserSupplied);
652    }
653}