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}