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};
#[derive(Debug, Clone, PartialEq, Eq)]
enum KeepToken {
Addr(usize),
Range(usize, usize),
Path(String),
}
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())
}
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))
}
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)
}
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))
}
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();
let mut i = 0;
while i < len && msgs[i].role == Role::System {
keep.insert(i);
i += 1;
}
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);
}
}
for tok in tokens {
match parse_token(tok) {
KeepToken::Addr(n) => {
if n < len {
keep.insert(n);
}
}
KeepToken::Range(a, b) => {
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);
}
}
}
}
}
}
}
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
}
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectiveSource {
UserSupplied,
Drafted,
Fallback,
}
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)
}
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
}
#[derive(Debug, Clone)]
pub struct HandoffResult {
pub view: Vec<ChatMessage>,
pub log: ReductionLog,
pub kept_indices: Vec<usize>,
pub gap_ids: Vec<String>,
pub objective_source: ObjectiveSource,
pub objective_text: Option<String>,
}
fn base_policy() -> ReductionPolicy {
ReductionPolicy {
clear_turns_older_than: None,
..ReductionPolicy::default()
}
}
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();
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();
#[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);
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();
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);
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");
}
}
#[test]
fn keep_range_with_huge_upper_bound_returns_promptly_and_clamps() {
let msgs = plain_session(10); 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");
}
for i in 1..5 {
assert!(
!keep_set.contains(&i),
"index {i} is below the range and must not be kept"
);
}
assert!(keep.iter().all(|&i| i < len));
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);
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);
let (obj, src) = resolve_objective(&msgs, None, false, None);
assert_eq!(obj, None);
assert_eq!(src, ObjectiveSource::Fallback);
let (obj, src) = resolve_objective(&msgs, None, true, Some(&FailingSummarizer));
assert_eq!(obj, None);
assert_eq!(src, ObjectiveSource::Fallback);
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);
}
}