Skip to main content

atman_runtime/
compaction.rs

1use crate::message::{Message, MessagePart, MessageRole};
2
3pub const KEEP_RECENT_MESSAGES: usize = 10;
4pub const KEEP_RECENT_USER_TURNS: usize = 5;
5const KEEP_RECENT_TOKEN_FRACTION: f64 = 0.05;
6const COMPACTION_SAFETY_MARGIN_MIN: u64 = 2_000;
7const COMPACTION_SAFETY_MARGIN_MAX_RATIO: f64 = 0.05;
8
9#[derive(Debug, Clone, Copy, Default)]
10pub struct CompactionBudgetContext {
11    pub fixed_input_tokens: Option<u64>,
12}
13
14impl CompactionBudgetContext {
15    pub fn estimated_input_tokens(self, message_tokens: u64) -> u64 {
16        message_tokens.saturating_add(self.fixed_input_tokens.unwrap_or(0))
17    }
18
19    pub fn history_budget(self, info: &crate::model_registry::ModelInfo) -> Option<u64> {
20        let fixed_input_tokens = self.fixed_input_tokens?;
21        let output_cap = (info.context_budget as f64 * 0.20) as u64;
22        let output_floor = 8_000_u64.min(output_cap);
23        let output_reserve = (info.max_output_tokens.unwrap_or(32_000) as u64)
24            .max(output_floor)
25            .min(output_cap);
26        let safety_cap = (info.context_budget as f64 * COMPACTION_SAFETY_MARGIN_MAX_RATIO) as u64;
27        let safety_margin = ((info.context_budget as f64 * 0.02) as u64)
28            .max(COMPACTION_SAFETY_MARGIN_MIN.min(safety_cap))
29            .min(safety_cap);
30        Some(
31            info.context_budget
32                .saturating_sub(output_reserve)
33                .saturating_sub(safety_margin)
34                .saturating_sub(fixed_input_tokens),
35        )
36    }
37}
38
39pub fn estimate_tokens_for_message(msg: &Message) -> u64 {
40    let mut chars = 0usize;
41    let mut fixed_tokens = 0u64;
42    for part in &msg.parts {
43        chars += match part {
44            MessagePart::FinalAnswerSummary { .. } => 0,
45            MessagePart::ContextRecord(record) => record.render_for_model().len(),
46            MessagePart::CompactSummary { summary, .. } => summary.len(),
47            MessagePart::Text { text } => text.len(),
48            MessagePart::Thinking { thinking, .. } => thinking.len(),
49            MessagePart::ToolResult { content, .. } => content.len(),
50            MessagePart::Image { source } => {
51                fixed_tokens = fixed_tokens.saturating_add(match source.detail {
52                    crate::provider::ImageDetail::Low => 85,
53                    crate::provider::ImageDetail::Auto => 1_024,
54                    crate::provider::ImageDetail::High => 1_536,
55                    crate::provider::ImageDetail::Original => 2_048,
56                });
57                0
58            }
59            MessagePart::ToolUse {
60                name,
61                input,
62                intent,
63                ..
64            } => {
65                name.len()
66                    + input.to_string().len()
67                    + intent.as_ref().map_or(0, |intent| {
68                        crate::message::TOOL_CALL_INTENT_FIELD.len() + intent.as_str().len() + 5
69                    })
70            }
71        };
72    }
73    chars = chars.saturating_add(estimate_role_overhead(msg.role));
74    (chars as f64 / 3.5).ceil() as u64 + fixed_tokens
75}
76
77fn estimate_role_overhead(role: MessageRole) -> usize {
78    match role {
79        MessageRole::System => 12,
80        MessageRole::User => 8,
81        MessageRole::Assistant => 8,
82        MessageRole::Tool => 16,
83    }
84}
85
86pub fn estimate_tokens_for_messages(messages: &[Message]) -> u64 {
87    messages.iter().map(estimate_tokens_for_message).sum()
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CompactRange {
92    pub start: usize,
93    pub end: usize,
94    pub tokens_saved_estimate: u64,
95}
96
97pub fn is_plan_related(msg: &Message) -> bool {
98    for part in &msg.parts {
99        match part {
100            MessagePart::ToolUse { name, .. } if name.starts_with("plan.") => return true,
101            MessagePart::ToolResult { content, .. } if content.starts_with("# Plan:") => {
102                return true;
103            }
104            _ => {}
105        }
106    }
107    false
108}
109
110pub fn is_compaction_summary(msg: &Message) -> bool {
111    if !matches!(msg.role, MessageRole::System) {
112        return false;
113    }
114    msg.parts
115        .iter()
116        .any(|part| matches!(part, MessagePart::CompactSummary { .. }))
117}
118
119fn find_kth_recent_user(messages: &[Message], k: usize) -> usize {
120    let mut user_count = 0;
121    for (index, message) in messages.iter().enumerate().rev() {
122        if message.role == MessageRole::User {
123            user_count += 1;
124            if user_count == k {
125                return index;
126            }
127        }
128    }
129    0
130}
131
132fn align_compact_end_to_tool_transactions(messages: &[Message], end: usize) -> usize {
133    let mut tool_use_messages = std::collections::HashMap::new();
134    for (index, message) in messages.iter().take(end).enumerate() {
135        for part in &message.parts {
136            if let MessagePart::ToolUse { id, .. } = part {
137                tool_use_messages.entry(id.as_str()).or_insert(index);
138            }
139        }
140    }
141
142    let mut aligned_end = end;
143    for message in messages.iter().skip(end) {
144        for part in &message.parts {
145            if let MessagePart::ToolResult { tool_use_id, .. } = part
146                && let Some(&use_index) = tool_use_messages.get(tool_use_id.as_str())
147            {
148                aligned_end = aligned_end.min(use_index);
149            }
150        }
151    }
152    aligned_end
153}
154
155pub fn find_compact_range(messages: &[Message], budget: u64) -> Option<CompactRange> {
156    let total = estimate_tokens_for_messages(messages);
157    if total <= budget || messages.len() < 4 {
158        return None;
159    }
160
161    let start = messages
162        .iter()
163        .rposition(is_compaction_summary)
164        .unwrap_or(0);
165    let keep_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
166    let mut recent_tokens = 0u64;
167    let mut token_end = messages.len();
168    for (index, message) in messages.iter().enumerate().rev() {
169        recent_tokens = recent_tokens.saturating_add(estimate_tokens_for_message(message));
170        token_end = index;
171        if recent_tokens >= keep_recent_tokens {
172            break;
173        }
174    }
175    let message_end = messages.len().saturating_sub(KEEP_RECENT_MESSAGES);
176    let end = message_end
177        .min(token_end)
178        .min(find_kth_recent_user(messages, KEEP_RECENT_USER_TURNS));
179    let end = align_compact_end_to_tool_transactions(messages, end);
180    if end < start + 2 {
181        return None;
182    }
183
184    let tokens_saved_estimate = messages[start..end]
185        .iter()
186        .map(estimate_tokens_for_message)
187        .sum();
188    Some(CompactRange {
189        start,
190        end,
191        tokens_saved_estimate,
192    })
193}
194
195pub fn estimate_compacted_message_tokens(
196    messages: &[Message],
197    range: &CompactRange,
198    summary: &str,
199) -> u64 {
200    let turn_id = messages
201        .get(range.start)
202        .map(|m| m.turn_id.clone())
203        .unwrap_or_else(crate::event::TurnId::now);
204    let after = replace_range_with_summary(messages, range, summary.to_string(), turn_id);
205    estimate_tokens_for_messages(&after)
206}
207
208pub fn filter_orphan_tool_messages(messages: &mut Vec<Message>) {
209    crate::message::retain_complete_tool_pairs(messages);
210}
211
212pub fn find_compact_summaries(messages: &[Message]) -> Vec<CompactSummary> {
213    let mut out = Vec::new();
214    for (idx, msg) in messages.iter().enumerate() {
215        if let Some(summary) = compact_summary(msg) {
216            out.push(CompactSummary {
217                message_index: idx,
218                seq_start: summary.seq_start,
219                seq_end: summary.seq_end,
220                count: summary.count,
221            });
222        }
223    }
224    out
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct CompactSummary {
229    pub message_index: usize,
230    pub seq_start: u64,
231    pub seq_end: u64,
232    pub count: usize,
233}
234
235struct CompactSummaryPart {
236    seq_start: u64,
237    seq_end: u64,
238    count: usize,
239}
240
241fn extract_anchor(messages: &[Message]) -> Option<(String, &[Message])> {
242    let first = messages.first()?;
243    let summary = first.parts.iter().find_map(|part| match part {
244        MessagePart::CompactSummary { summary, .. } => Some(summary.clone()),
245        _ => None,
246    })?;
247    Some((summary, &messages[1..]))
248}
249
250fn compact_summary(msg: &Message) -> Option<CompactSummaryPart> {
251    if msg.role != MessageRole::System {
252        return None;
253    }
254    msg.parts.iter().find_map(|part| match part {
255        MessagePart::CompactSummary {
256            seq_start,
257            seq_end,
258            count,
259            ..
260        } => Some(CompactSummaryPart {
261            seq_start: *seq_start,
262            seq_end: *seq_end,
263            count: *count,
264        }),
265        _ => None,
266    })
267}
268
269pub async fn maybe_auto_compact(
270    session: &crate::session::Session,
271    model: &str,
272    providers: &crate::provider::ProviderRegistry,
273) {
274    maybe_auto_compact_with_budget(
275        session,
276        model,
277        providers,
278        CompactionBudgetContext::default(),
279    )
280    .await;
281}
282
283pub async fn maybe_auto_compact_with_budget(
284    session: &crate::session::Session,
285    model: &str,
286    providers: &crate::provider::ProviderRegistry,
287    budget_context: CompactionBudgetContext,
288) {
289    let _compact_guard = session.acquire_compact_lock().await;
290    maybe_auto_compact_locked(session, model, providers, budget_context).await;
291}
292
293pub fn spawn_auto_compact(
294    session: std::sync::Arc<crate::session::Session>,
295    model: String,
296    providers: crate::provider::ProviderRegistry,
297) {
298    tokio::task::spawn_blocking(move || {
299        let Ok(rt) = tokio::runtime::Builder::new_current_thread()
300            .enable_all()
301            .build()
302        else {
303            session.push_system_note("compaction skipped: background runtime init failed".into());
304            return;
305        };
306        rt.block_on(async move {
307            maybe_auto_compact(&session, &model, &providers).await;
308        });
309    });
310}
311
312pub async fn start_auto_compact(
313    session: std::sync::Arc<crate::session::Session>,
314    model: String,
315    providers: crate::provider::ProviderRegistry,
316) {
317    start_auto_compact_with_budget(
318        session,
319        model,
320        providers,
321        CompactionBudgetContext::default(),
322    )
323    .await;
324}
325
326pub async fn start_auto_compact_with_budget(
327    session: std::sync::Arc<crate::session::Session>,
328    model: String,
329    providers: crate::provider::ProviderRegistry,
330    budget_context: CompactionBudgetContext,
331) {
332    let compact_guard = session.acquire_compact_lock_owned().await;
333    tokio::task::spawn_blocking(move || {
334        let Ok(rt) = tokio::runtime::Builder::new_current_thread()
335            .enable_all()
336            .build()
337        else {
338            drop(compact_guard);
339            session.push_system_note("compaction skipped: background runtime init failed".into());
340            return;
341        };
342        rt.block_on(async move {
343            maybe_auto_compact_locked(&session, &model, &providers, budget_context).await;
344            drop(compact_guard);
345        });
346    });
347}
348
349async fn maybe_auto_compact_locked(
350    session: &crate::session::Session,
351    model: &str,
352    providers: &crate::provider::ProviderRegistry,
353    budget_context: CompactionBudgetContext,
354) {
355    let forced = session.take_manual_compact_request();
356    let info = crate::model_registry::model_info(model);
357    let trigger = info.compaction_trigger_threshold();
358    let target = budget_context
359        .history_budget(&info)
360        .map(|budget| budget.min(info.compaction_target_after()))
361        .unwrap_or_else(|| info.compaction_target_after());
362    let msgs = session.messages();
363    let window_tokens = estimate_tokens_for_messages(&msgs);
364    let estimate = budget_context.estimated_input_tokens(window_tokens);
365    let current = providers.resolve(model).map_or(estimate, |provider| {
366        session.calibrated_context_input_estimate(provider.name(), model, estimate)
367    });
368    if !forced && current <= trigger {
369        return;
370    }
371    if !forced && !session.approval_cooldown_ok_for_compact() {
372        return;
373    }
374    let Some(range) = find_compact_range(&msgs, target) else {
375        let (replacement, rewritten_count) =
376            build_budgeted_turn_rewrite(&msgs, target, model, providers).await;
377        let after_tokens = estimate_tokens_for_messages(&replacement);
378        if rewritten_count == 0 || after_tokens >= window_tokens || after_tokens > target {
379            session.emit_compact_warning(
380                model,
381                current,
382                trigger,
383                info.context_budget,
384                "no compactible span — retained user content cannot fit the history budget",
385            );
386            return;
387        }
388        match session.commit_rewritten_window(
389            replacement,
390            window_tokens,
391            window_tokens,
392            rewritten_count,
393        ) {
394            Some(_) => {}
395            None => {
396                session.emit_compact_warning(
397                    model,
398                    current,
399                    trigger,
400                    info.context_budget,
401                    "retained turn output rewrite did not shrink the transcript",
402                );
403            }
404        }
405        return;
406    };
407    let _ = session
408        .stream_tx()
409        .send(crate::stream::StreamFrame::CompactionSummary {
410            phase: crate::stream::CompactionPhase::Running,
411            range_start: range.start,
412            range_end: range.end.saturating_sub(1),
413            summary: String::new(),
414            before_tokens: current,
415            after_tokens: 0,
416            compacted_count: range.end - range.start,
417        });
418    let send_failed = |session: &crate::session::Session, reason: &str| {
419        let _ = session
420            .stream_tx()
421            .send(crate::stream::StreamFrame::CompactionSummary {
422                phase: crate::stream::CompactionPhase::Failed,
423                range_start: range.start,
424                range_end: range.end.saturating_sub(1),
425                summary: reason.to_string(),
426                before_tokens: current,
427                after_tokens: current,
428                compacted_count: range.end - range.start,
429            });
430    };
431    let mut filtered: Vec<Message> = msgs[range.start..range.end].to_vec();
432    filter_orphan_tool_messages(&mut filtered);
433    let (anchor, new_messages) = extract_anchor(&filtered)
434        .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
435        .unwrap_or_else(|| (None, filtered.clone()));
436    let range_start = range.start;
437    let range_end = range.end.saturating_sub(1);
438    let stream_tx = session.stream_tx();
439    let on_delta: std::sync::Arc<dyn Fn(String) + Send + Sync> = std::sync::Arc::new(move |text| {
440        let _ = stream_tx.send(crate::stream::StreamFrame::CompactionDelta {
441            range_start,
442            range_end,
443            text,
444        });
445    });
446    let summary = match generate_llm_summary_with_delta(
447        anchor.as_deref(),
448        &new_messages,
449        model,
450        providers,
451        Some(on_delta),
452    )
453    .await
454    {
455        Ok(text) => text,
456        Err(err) => {
457            send_failed(session, &format!("LLM summary failed: {err}"));
458            session.emit_compact_warning(
459                model,
460                current,
461                trigger,
462                info.context_budget,
463                &format!("LLM summary failed: {err}; keeping full transcript"),
464            );
465            return;
466        }
467    };
468    let final_summary =
469        match request_review_if_enabled(session, forced, &filtered, &range, current, summary).await
470        {
471            ReviewOutcome::Commit(s) => s,
472            ReviewOutcome::Rejected => {
473                send_failed(
474                    session,
475                    "compaction rejected by user; keeping full transcript",
476                );
477                session.push_system_note(
478                    "compaction rejected by user; keeping full transcript".into(),
479                );
480                return;
481            }
482        };
483    let replacement =
484        build_budgeted_replacement(&msgs, &range, &final_summary, target, model, providers).await;
485    let after_tokens = estimate_tokens_for_messages(&replacement);
486    if after_tokens >= window_tokens {
487        send_failed(
488            session,
489            &format!(
490                "compaction skipped: replacement would not shrink transcript ({} >= {} tokens)",
491                after_tokens, window_tokens
492            ),
493        );
494        session.push_system_note(format!(
495            "compaction skipped: replacement would not shrink transcript ({} >= {} tokens)",
496            after_tokens, window_tokens
497        ));
498        return;
499    }
500    match session.commit_compacted_window(
501        final_summary,
502        replacement,
503        range,
504        window_tokens,
505        window_tokens,
506    ) {
507        Some(result) => {
508            session.push_system_note(format!(
509                "auto-compacted {}..{} — {} → {} tokens",
510                result.compacted_start,
511                result.compacted_end,
512                result.before_tokens,
513                result.after_tokens
514            ));
515        }
516        None => {
517            session.emit_compact_warning(
518                model,
519                current,
520                trigger,
521                info.context_budget,
522                "no compactible span — history too short or already fully compacted",
523            );
524        }
525    }
526}
527
528enum ReviewOutcome {
529    Commit(String),
530    Rejected,
531}
532
533async fn request_review_if_enabled(
534    session: &crate::session::Session,
535    forced: bool,
536    slice: &[Message],
537    range: &CompactRange,
538    tokens_before: u64,
539    summary: String,
540) -> ReviewOutcome {
541    if !session.compact_review_mode().should_review(forced) {
542        return ReviewOutcome::Commit(summary);
543    }
544    let reviews = session.compact_reviews();
545    if reviews.subscriber_count() == 0 {
546        return ReviewOutcome::Commit(summary);
547    }
548    let pending = crate::session::PendingCompactReview {
549        review_id: uuid::Uuid::now_v7().to_string(),
550        summary: summary.clone(),
551        slice_preview: format_slice_for_preview(slice),
552        slice_count: slice.len(),
553        range_start: range.start,
554        range_end: range.end,
555        tokens_before,
556        emitted_at: chrono::Utc::now(),
557    };
558    let rx = reviews.request(pending);
559    match rx.await {
560        Ok(crate::session::CompactReviewDecision::AcceptAsIs) => ReviewOutcome::Commit(summary),
561        Ok(crate::session::CompactReviewDecision::AcceptEdited { summary: edited }) => {
562            ReviewOutcome::Commit(edited)
563        }
564        Ok(crate::session::CompactReviewDecision::Reject) | Err(_) => ReviewOutcome::Rejected,
565    }
566}
567
568fn format_slice_for_preview(slice: &[Message]) -> String {
569    let mut out = String::new();
570    for (i, msg) in slice.iter().enumerate() {
571        let role = msg.role.as_str();
572        let body = serialize_message_for_summary(msg);
573        let truncated: String = body.chars().take(400).collect();
574        out.push_str(&format!("[{i}] {role}: {truncated}\n"));
575    }
576    out.chars().take(16_000).collect()
577}
578
579const SUMMARY_SYSTEM_PROMPT: &str =
580    "You are an anchored context summarization assistant for coding sessions.";
581
582const SUMMARY_INSTRUCTIONS: &str = r#"You are an anchored context summarization assistant.
583
584Below is:
5851. <current-anchor>: the existing handoff state, which is authoritative and must be preserved.
5862. <new-messages>: only the messages that arrived since the anchor was written.
587
588Merge the NEW facts from <new-messages> INTO the current anchor, producing an upgraded full anchor.
589
590STRUCTURAL RULES (data model, not optional style):
591- ## Objective: unchanged unless the new messages show the user explicitly redirected.
592- ### Completed: ONLY ADD newly completed items. Never remove or re-evaluate an existing completed item. If a completed item is now in question, add it to ### Active or ### Blocked instead. NEVER delete from Completed.
593- ### Active: update based on new messages; move newly-done items to Completed.
594- ### Blocked: update based on new messages; remove resolved ones.
595- ## Decisions: only add new decisions. Never remove old ones.
596- ## Next Move: replace based on current end state.
597- Keep every section, even when empty.
598- Preserve exact file paths, symbols, commands, error strings, identifiers.
599
600Output exactly this Markdown structure:
601## Objective
602## Important Details
603## Work State
604### Completed
605### Active
606### Blocked
607## Decisions
608## Next Move
609## Relevant Files
610
611Do not mention the summary process or that context was compacted.
612Respond in the same language as the conversation."#;
613
614async fn generate_llm_summary(
615    anchor: Option<&str>,
616    slice: &[Message],
617    model: &str,
618    providers: &crate::provider::ProviderRegistry,
619) -> Result<String, crate::error::RuntimeError> {
620    generate_llm_summary_with_delta(anchor, slice, model, providers, None).await
621}
622
623async fn generate_llm_summary_with_delta(
624    anchor: Option<&str>,
625    slice: &[Message],
626    model: &str,
627    providers: &crate::provider::ProviderRegistry,
628    on_delta: Option<std::sync::Arc<dyn Fn(String) + Send + Sync>>,
629) -> Result<String, crate::error::RuntimeError> {
630    let provider = providers.resolve(model).ok_or_else(|| {
631        crate::error::RuntimeError::ToolFailed(format!("no provider for {model}"))
632    })?;
633    let payload = format_slice_for_summary(slice);
634    let (messages, dump_user) = if let Some(anchor) = anchor {
635        let anchor_user = format!("<current-anchor>\n{anchor}\n</current-anchor>");
636        let new_user =
637            format!("<new-messages>\n{payload}\n</new-messages>\n\n{SUMMARY_INSTRUCTIONS}");
638        (
639            vec![
640                Message::user_text(crate::event::TurnId::now(), anchor_user.clone()),
641                Message::user_text(crate::event::TurnId::now(), new_user.clone()),
642            ],
643            format!("{anchor_user}\n\n{new_user}"),
644        )
645    } else {
646        let user = format!(
647            "<conversation_history>\n{payload}\n</conversation_history>\n\n{SUMMARY_INSTRUCTIONS}"
648        );
649        (
650            vec![Message::user_text(
651                crate::event::TurnId::now(),
652                user.clone(),
653            )],
654            user,
655        )
656    };
657    if let Ok(dir) = std::env::var("ATMAN_COMPACT_DUMP") {
658        let _ = std::fs::write(
659            format!("{dir}/compact_request.txt"),
660            format!("=== SYSTEM ===\n{SUMMARY_SYSTEM_PROMPT}\n\n=== USER ===\n{dump_user}"),
661        );
662    }
663    let req = crate::provider::LlmRequest {
664        model: crate::model_registry::api_model_id(model),
665        messages,
666        system: Some(SUMMARY_SYSTEM_PROMPT.into()),
667        input: crate::value::Value::Unit,
668        schema: None,
669        cache_prompt: false,
670        prompt_cache_key: None,
671        tools: Vec::new(),
672        reasoning: crate::provider::ReasoningSelection::ProviderDefault,
673        stall_timeout_secs: 0,
674    };
675    let outcome = if let Some(on_delta) = on_delta {
676        let observable = provider.call_streaming(req);
677        let mut events = observable.events;
678        let mut output = observable.output;
679        let outcome = loop {
680            tokio::select! {
681                event = events.recv() => match event {
682                    Ok(crate::event::NodeEvent::LlmChunk { text, .. }) => {
683                        on_delta(text);
684                    }
685                    Ok(_) => {}
686                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
687                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break output.await?,
688                },
689                result = &mut output => break result?,
690            }
691        };
692        while let Ok(event) = events.try_recv() {
693            if let crate::event::NodeEvent::LlmChunk { text, .. } = event {
694                on_delta(text);
695            }
696        }
697        outcome
698    } else {
699        provider.call(req).await?
700    };
701    let text = outcome.text_concat();
702    if text.trim().is_empty() {
703        return Err(crate::error::RuntimeError::ToolFailed(
704            "empty summary from provider".into(),
705        ));
706    }
707    Ok(text)
708}
709
710async fn build_budgeted_turn_rewrite(
711    messages: &[Message],
712    history_budget: u64,
713    model: &str,
714    providers: &crate::provider::ProviderRegistry,
715) -> (Vec<Message>, usize) {
716    let mut replacement = messages.to_vec();
717    let mut group_index = 0;
718    let mut rewritten_count = 0;
719    while estimate_tokens_for_messages(&replacement) > history_budget {
720        let groups = user_turn_ranges(&replacement);
721        let Some((start, end)) = groups.get(group_index).copied() else {
722            break;
723        };
724        let output = replacement[start + 1..end].to_vec();
725        if output.is_empty() {
726            group_index += 1;
727            continue;
728        }
729        let output_tokens = estimate_tokens_for_messages(&output);
730        let summary = generate_llm_summary(None, &output, model, providers)
731            .await
732            .unwrap_or_else(|_| deterministic_turn_omission(&output));
733        let mut summary_message = Message::assistant_text(
734            replacement[start].turn_id.clone(),
735            format!("[atman: compacted turn output]\n{summary}\n[/atman: compacted turn output]"),
736        );
737        if estimate_tokens_for_message(&summary_message) >= output_tokens {
738            summary_message = Message::assistant_text(
739                replacement[start].turn_id.clone(),
740                deterministic_turn_omission(&output),
741            );
742        }
743        rewritten_count += output.len();
744        replacement.splice(start + 1..end, [summary_message]);
745        group_index += 1;
746    }
747    filter_orphan_tool_messages(&mut replacement);
748    (replacement, rewritten_count)
749}
750
751async fn build_budgeted_replacement(
752    messages: &[Message],
753    range: &CompactRange,
754    anchor_summary: &str,
755    history_budget: u64,
756    model: &str,
757    providers: &crate::provider::ProviderRegistry,
758) -> Vec<Message> {
759    let turn_id = messages
760        .get(range.start)
761        .map(|message| message.turn_id.clone())
762        .unwrap_or_else(crate::event::TurnId::now);
763    let mut replacement =
764        replace_range_with_summary(messages, range, anchor_summary.to_string(), turn_id);
765    filter_orphan_tool_messages(&mut replacement);
766    if estimate_tokens_for_messages(&replacement) <= history_budget {
767        return replacement;
768    }
769
770    let mut group_index = 0;
771    loop {
772        let groups = user_turn_ranges(&replacement);
773        if group_index >= groups.len()
774            || estimate_tokens_for_messages(&replacement) <= history_budget
775        {
776            break;
777        }
778        let (start, end) = groups[group_index];
779        let output: Vec<Message> = replacement[start + 1..end].to_vec();
780        if output.is_empty() {
781            group_index += 1;
782            continue;
783        }
784        let output_tokens = estimate_tokens_for_messages(&output);
785        let summary = generate_llm_summary(None, &output, model, providers)
786            .await
787            .unwrap_or_else(|_| deterministic_turn_omission(&output));
788        let mut summary_message = Message::assistant_text(
789            replacement[start].turn_id.clone(),
790            format!("[atman: compacted turn output]\n{summary}\n[/atman: compacted turn output]"),
791        );
792        if estimate_tokens_for_message(&summary_message) >= output_tokens {
793            summary_message = Message::assistant_text(
794                replacement[start].turn_id.clone(),
795                deterministic_turn_omission(&output),
796            );
797        }
798        replacement.splice(start + 1..end, [summary_message]);
799        group_index += 1;
800    }
801
802    if estimate_tokens_for_messages(&replacement) > history_budget {
803        let groups = user_turn_ranges(&replacement);
804        for (start, end) in groups.into_iter().rev() {
805            let output = replacement[start + 1..end].to_vec();
806            if !output.is_empty() {
807                replacement.splice(
808                    start + 1..end,
809                    [Message::assistant_text(
810                        replacement[start].turn_id.clone(),
811                        deterministic_turn_omission(&output),
812                    )],
813                );
814            }
815        }
816    }
817
818    if estimate_tokens_for_messages(&replacement) > history_budget {
819        replacement = compaction_floor(&replacement);
820    }
821
822    filter_orphan_tool_messages(&mut replacement);
823    replacement
824}
825
826fn user_turn_ranges(messages: &[Message]) -> Vec<(usize, usize)> {
827    let starts: Vec<usize> = messages
828        .iter()
829        .enumerate()
830        .filter_map(|(index, message)| (message.role == MessageRole::User).then_some(index))
831        .collect();
832    starts
833        .iter()
834        .enumerate()
835        .map(|(index, start)| {
836            (
837                *start,
838                starts.get(index + 1).copied().unwrap_or(messages.len()),
839            )
840        })
841        .collect()
842}
843
844fn deterministic_turn_omission(messages: &[Message]) -> String {
845    format!(
846        "[atman: omitted {} oversized assistant/system/tool messages during persistent compaction]",
847        messages.len()
848    )
849}
850
851fn compaction_floor(messages: &[Message]) -> Vec<Message> {
852    let mut out = Vec::new();
853    if let Some(anchor) = messages
854        .iter()
855        .find(|message| is_compaction_summary(message))
856    {
857        out.push(anchor.clone());
858    }
859
860    let mut latest =
861        std::collections::HashMap::<&str, (&crate::context_plan::ContextRecord, &Message)>::new();
862    for message in messages {
863        for part in &message.parts {
864            if let MessagePart::ContextRecord(record) = part {
865                latest
866                    .entry(record.key())
867                    .and_modify(|(current, source)| {
868                        if record.revision() >= current.revision() {
869                            *current = record;
870                            *source = message;
871                        }
872                    })
873                    .or_insert((record, message));
874            }
875        }
876    }
877    let mut records: Vec<_> = latest.into_values().collect();
878    records.sort_by(|(left, _), (right, _)| left.key().cmp(right.key()));
879    out.extend(
880        records.into_iter().map(|(record, source)| {
881            Message::context_record(source.turn_id.clone(), record.clone())
882        }),
883    );
884
885    out.extend(messages.iter().filter_map(|message| {
886        if message.role != MessageRole::User {
887            return None;
888        }
889        let mut user = message.clone();
890        user.parts.retain(|part| {
891            !matches!(
892                part,
893                MessagePart::CompactSummary { .. } | MessagePart::ContextRecord(_)
894            )
895        });
896        (!user.parts.is_empty()).then_some(user)
897    }));
898    out
899}
900
901fn format_slice_for_summary(slice: &[Message]) -> String {
902    let mut out = String::new();
903    for (i, msg) in slice.iter().enumerate() {
904        let role = msg.role.as_str();
905        let body = serialize_message_for_summary(msg);
906        let truncated: String = body.chars().take(4000).collect();
907        out.push_str(&format!("[{i}] {role}: {truncated}\n\n"));
908    }
909    out.chars().take(120_000).collect()
910}
911
912fn serialize_message_for_summary(msg: &Message) -> String {
913    let mut parts = Vec::new();
914    for part in &msg.parts {
915        match part {
916            MessagePart::FinalAnswerSummary { .. } => {}
917            MessagePart::ContextRecord(record) => {
918                if record.retention() == crate::context_plan::ContextRecordRetention::Timeline {
919                    parts.push(record.render_for_model());
920                }
921            }
922            MessagePart::CompactSummary { summary, .. } => {
923                parts.push(summary.clone());
924            }
925            MessagePart::Text { text } => {
926                parts.push(text.clone());
927            }
928            MessagePart::Thinking { thinking, .. } => {
929                let truncated: String = thinking.chars().take(1000).collect();
930                parts.push(format!("[thinking: {truncated}]"));
931            }
932            MessagePart::ToolUse {
933                name,
934                input,
935                intent,
936                ..
937            } => {
938                let input_str = if input.is_null() {
939                    String::new()
940                } else {
941                    input.to_string()
942                };
943                let truncated: String = input_str.chars().take(2000).collect();
944                let purpose = intent
945                    .as_ref()
946                    .map(|intent| format!(" purpose={}", intent.as_str()))
947                    .unwrap_or_default();
948                parts.push(format!("[tool_call: {name}{purpose}({truncated})]"));
949            }
950            MessagePart::ToolResult {
951                content,
952                is_error,
953                tool_use_id,
954            } => {
955                let truncated: String = content.chars().take(3000).collect();
956                let marker = if *is_error { "ERROR" } else { "ok" };
957                let id_short: String = tool_use_id.chars().take(12).collect();
958                parts.push(format!("[tool_result {id_short}… {marker}: {truncated}]"));
959            }
960            MessagePart::Image { .. } => {
961                parts.push("[image]".into());
962            }
963        }
964    }
965    parts.join(" ")
966}
967
968pub fn replace_range_with_summary(
969    messages: &[Message],
970    range: &CompactRange,
971    summary: String,
972    turn_id: crate::event::TurnId,
973) -> Vec<Message> {
974    let retained_records = latest_context_records_before(messages, range.end);
975    let mut out =
976        Vec::with_capacity(1 + retained_records.len() + messages.len().saturating_sub(range.end));
977    out.push(Message::system_compact_summary(
978        turn_id,
979        summary,
980        range.start as u64,
981        range.end.saturating_sub(1) as u64,
982        range.end - range.start,
983    ));
984    out.extend(retained_records);
985    out.extend_from_slice(&messages[range.end..]);
986    out
987}
988
989fn latest_context_records_before(messages: &[Message], end: usize) -> Vec<Message> {
990    let suffix_keys: std::collections::HashSet<&str> = messages[end..]
991        .iter()
992        .flat_map(|message| &message.parts)
993        .filter_map(|part| match part {
994            MessagePart::ContextRecord(record) => Some(record.key()),
995            _ => None,
996        })
997        .collect();
998    let mut latest = std::collections::HashMap::<&str, (usize, &Message, &MessagePart)>::new();
999    for (index, message) in messages[..end].iter().enumerate() {
1000        for part in &message.parts {
1001            if let MessagePart::ContextRecord(record) = part
1002                && record.retention() == crate::context_plan::ContextRecordRetention::Latest
1003                && !suffix_keys.contains(record.key())
1004            {
1005                latest.insert(record.key(), (index, message, part));
1006            }
1007        }
1008    }
1009    let mut retained: Vec<_> = latest.into_values().collect();
1010    retained.sort_by_key(|(index, _, _)| *index);
1011    retained
1012        .into_iter()
1013        .map(|(_, message, part)| Message {
1014            role: MessageRole::System,
1015            parts: vec![part.clone()],
1016            turn_id: message.turn_id.clone(),
1017            origin: crate::message::MessageOrigin::Internal,
1018        })
1019        .collect()
1020}
1021
1022/// Result of compacting a messages_handle in place.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct HandleCompactResult {
1025    pub before_tokens: u64,
1026    pub after_tokens: u64,
1027    pub compacted_start: usize,
1028    pub compacted_end: usize,
1029}
1030
1031/// Result of applying the automatic compaction policy to an isolated message
1032/// handle. The caller must hold that handle's async compaction lock.
1033#[derive(Debug, Clone, PartialEq)]
1034pub struct HandleAutoCompactResult {
1035    pub before_tokens: u64,
1036    pub after_tokens: u64,
1037    pub compacted_start: usize,
1038    pub compacted_end: usize,
1039    pub compacted_count: usize,
1040    pub summary: String,
1041    pub checkpoint_messages: Vec<Message>,
1042}
1043
1044/// Apply the root compaction budget, range, summary, and replacement policy to
1045/// an isolated message handle. This function does not acquire the async lock
1046/// and does not emit session events.
1047pub async fn maybe_auto_compact_handle_locked(
1048    handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
1049    model: &str,
1050    providers: &crate::provider::ProviderRegistry,
1051    budget_context: CompactionBudgetContext,
1052    forced: bool,
1053) -> Option<HandleAutoCompactResult> {
1054    let snapshot = handle.lock().unwrap().clone();
1055    let info = crate::model_registry::model_info(model);
1056    let trigger = info.compaction_trigger_threshold();
1057    let target = budget_context
1058        .history_budget(&info)
1059        .map(|budget| budget.min(info.compaction_target_after()))
1060        .unwrap_or_else(|| info.compaction_target_after());
1061    let before_tokens = estimate_tokens_for_messages(&snapshot);
1062    let current = budget_context.estimated_input_tokens(before_tokens);
1063    if !forced && current <= trigger {
1064        return None;
1065    }
1066
1067    let (replacement, summary, compacted_start, compacted_end, compacted_count, must_fit_target) =
1068        if let Some(range) = find_compact_range(&snapshot, target) {
1069            let mut filtered = snapshot[range.start..range.end].to_vec();
1070            filter_orphan_tool_messages(&mut filtered);
1071            let (anchor, new_messages) = extract_anchor(&filtered)
1072                .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
1073                .unwrap_or_else(|| (None, filtered));
1074            let summary = generate_llm_summary(anchor.as_deref(), &new_messages, model, providers)
1075                .await
1076                .ok()?;
1077            let replacement =
1078                build_budgeted_replacement(&snapshot, &range, &summary, target, model, providers)
1079                    .await;
1080            let compacted_end = range.end.saturating_sub(1);
1081            let compacted_count = range.end - range.start;
1082            (
1083                replacement,
1084                summary,
1085                range.start,
1086                compacted_end,
1087                compacted_count,
1088                false,
1089            )
1090        } else {
1091            let (replacement, rewritten_count) =
1092                build_budgeted_turn_rewrite(&snapshot, target, model, providers).await;
1093            if rewritten_count == 0 {
1094                return None;
1095            }
1096            (
1097                replacement,
1098                format!(
1099                    "[atman: persistently compacted output from {rewritten_count} retained messages]"
1100                ),
1101                0,
1102                0,
1103                rewritten_count,
1104                true,
1105            )
1106        };
1107    let after_tokens = estimate_tokens_for_messages(&replacement);
1108    if after_tokens >= before_tokens || (must_fit_target && after_tokens > target) {
1109        return None;
1110    }
1111
1112    let mut messages = handle.lock().unwrap();
1113    if *messages != snapshot {
1114        return None;
1115    }
1116    *messages = replacement.clone();
1117    Some(HandleAutoCompactResult {
1118        before_tokens,
1119        after_tokens,
1120        compacted_start,
1121        compacted_end,
1122        compacted_count,
1123        summary,
1124        checkpoint_messages: replacement,
1125    })
1126}
1127
1128/// Compact a messages_handle in place (data-layer primitive, operates on any
1129/// FlowRun's segment). Returns `None` if under `budget` or no compactable
1130/// range. Caller should hold the FlowRun's `compact_lock`.
1131pub fn compact_messages_on_handle(
1132    handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
1133    summary: String,
1134    budget: u64,
1135) -> Option<HandleCompactResult> {
1136    let mut msgs = handle.lock().unwrap();
1137    let before_tokens = estimate_tokens_for_messages(&msgs);
1138    let range = find_compact_range(&msgs, budget)?;
1139    let turn_id = msgs
1140        .get(range.start)
1141        .map(|m| m.turn_id.clone())
1142        .unwrap_or_else(crate::event::TurnId::now);
1143    let after = replace_range_with_summary(&msgs, &range, summary, turn_id);
1144    let after_tokens = estimate_tokens_for_messages(&after);
1145    if after_tokens >= before_tokens {
1146        return None;
1147    }
1148    let result = HandleCompactResult {
1149        before_tokens,
1150        after_tokens,
1151        compacted_start: range.start,
1152        compacted_end: range.end.saturating_sub(1),
1153    };
1154    *msgs = after;
1155    Some(result)
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161    use crate::event::TurnId;
1162    use crate::message::MessageOrigin;
1163
1164    fn user(text: &str) -> Message {
1165        Message::user_text(TurnId::now(), text)
1166    }
1167    fn assistant(text: &str) -> Message {
1168        Message::assistant_text(TurnId::now(), text)
1169    }
1170    fn system(text: &str) -> Message {
1171        Message::system_text(TurnId::now(), text)
1172    }
1173
1174    fn context_record(key: &str, revision: u64, text: &str) -> Message {
1175        Message::context_record(
1176            TurnId::now(),
1177            crate::context_plan::ContextRecord::new(
1178                key,
1179                revision,
1180                crate::context_plan::ContextRecordAuthority::Runtime,
1181                crate::context_plan::ContextRecordRetention::Latest,
1182                crate::context_plan::ContextRecordBody::text(text),
1183            ),
1184        )
1185    }
1186
1187    fn context_tombstone(key: &str, revision: u64) -> Message {
1188        Message::context_record(
1189            TurnId::now(),
1190            crate::context_plan::ContextRecord::new(
1191                key,
1192                revision,
1193                crate::context_plan::ContextRecordAuthority::Runtime,
1194                crate::context_plan::ContextRecordRetention::Latest,
1195                crate::context_plan::ContextRecordBody::tombstone(),
1196            ),
1197        )
1198    }
1199
1200    #[test]
1201    fn replacement_keeps_only_the_latest_live_record_per_key() {
1202        let messages = vec![
1203            user("old"),
1204            context_record("session.goal", 1, "first"),
1205            context_record("session.goal", 2, "second"),
1206            assistant("old answer"),
1207            user("current"),
1208        ];
1209        let replacement = replace_range_with_summary(
1210            &messages,
1211            &CompactRange {
1212                start: 0,
1213                end: 4,
1214                tokens_saved_estimate: 1,
1215            },
1216            "summary".into(),
1217            TurnId::now(),
1218        );
1219
1220        assert_eq!(replacement.len(), 3);
1221        assert!(is_compaction_summary(&replacement[0]));
1222        assert!(matches!(
1223            replacement[1].parts.as_slice(),
1224            [MessagePart::ContextRecord(record)]
1225                if record.key() == "session.goal" && record.revision() == 2
1226        ));
1227        assert_eq!(replacement[2].text_concat(), "current");
1228    }
1229
1230    #[test]
1231    fn compaction_budget_reserves_output_safety_and_fixed_input_only_at_compact_time() {
1232        let info = crate::model_registry::ModelInfo {
1233            name: "test".into(),
1234            context_budget: 100_000,
1235            compact_threshold_ratio: 0.8,
1236            reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1237            capabilities: crate::provider::ModelCapabilities::default(),
1238            image_detail: crate::provider::ImageDetail::Auto,
1239            max_output_tokens: Some(10_000),
1240        };
1241        let budget = CompactionBudgetContext {
1242            fixed_input_tokens: Some(5_000),
1243        }
1244        .history_budget(&info);
1245        assert_eq!(budget, Some(100_000 - 10_000 - 2_000 - 5_000));
1246    }
1247
1248    #[test]
1249    fn compaction_budget_saturates_when_fixed_input_exceeds_context() {
1250        let info = crate::model_registry::ModelInfo {
1251            name: "test".into(),
1252            context_budget: 20_000,
1253            compact_threshold_ratio: 0.8,
1254            reasoning: crate::provider::ReasoningSelection::ProviderDefault,
1255            capabilities: crate::provider::ModelCapabilities::default(),
1256            image_detail: crate::provider::ImageDetail::Auto,
1257            max_output_tokens: None,
1258        };
1259        assert_eq!(
1260            CompactionBudgetContext {
1261                fixed_input_tokens: Some(100_000)
1262            }
1263            .history_budget(&info),
1264            Some(0)
1265        );
1266    }
1267
1268    #[test]
1269    fn compaction_preflight_estimate_uses_current_messages_and_fixed_prefix() {
1270        let budget = CompactionBudgetContext {
1271            fixed_input_tokens: Some(7_000),
1272        };
1273        assert_eq!(budget.estimated_input_tokens(11_000), 18_000);
1274        assert_eq!(
1275            CompactionBudgetContext::default().estimated_input_tokens(11_000),
1276            11_000
1277        );
1278    }
1279
1280    #[tokio::test]
1281    async fn budgeted_replacement_groups_by_user_boundary_and_persists_omission() {
1282        let first_turn = TurnId::now();
1283        let second_turn = TurnId::now();
1284        let mut messages = vec![system(&"old".repeat(20_000)), assistant("old answer")];
1285        messages.push(Message::user_text(first_turn.clone(), "first user"));
1286        messages.push(Message::assistant_text(
1287            second_turn.clone(),
1288            "first output".repeat(4_000),
1289        ));
1290        messages.push(assistant_with_tool_use(
1291            "calling tool",
1292            "fs.read",
1293            serde_json::json!({"path": "/tmp/example"}),
1294        ));
1295        messages.push(tool_result(
1296            "call_test",
1297            &"tool output".repeat(4_000),
1298            false,
1299        ));
1300        messages.push(Message::user_text(second_turn.clone(), "current user"));
1301        messages.push(Message::assistant_text(
1302            first_turn,
1303            "current output".repeat(4_000),
1304        ));
1305        let range = CompactRange {
1306            start: 0,
1307            end: 2,
1308            tokens_saved_estimate: 1,
1309        };
1310
1311        let replacement = build_budgeted_replacement(
1312            &messages,
1313            &range,
1314            "anchor",
1315            500,
1316            "missing-provider",
1317            &crate::provider::ProviderRegistry::default(),
1318        )
1319        .await;
1320
1321        let texts: Vec<String> = replacement.iter().map(Message::text_concat).collect();
1322        assert!(texts.iter().any(|text| text == "current user"));
1323        assert!(
1324            texts
1325                .iter()
1326                .any(|text| text.contains("omitted 3 oversized"))
1327        );
1328        assert!(!replacement.iter().any(|message| {
1329            message.parts.iter().any(|part| {
1330                matches!(
1331                    part,
1332                    MessagePart::ToolUse { .. } | MessagePart::ToolResult { .. }
1333                )
1334            })
1335        }));
1336        assert_eq!(user_turn_ranges(&replacement).len(), 2);
1337    }
1338
1339    #[tokio::test]
1340    async fn budgeted_replacement_floor_keeps_anchor_records_and_user_inputs() {
1341        let messages = vec![
1342            system("old system"),
1343            context_record("session.goal", 1, "old goal"),
1344            context_record("session.goal", 2, "current goal"),
1345            context_tombstone("session.workspace", 3),
1346            user("first user"),
1347            assistant("first output"),
1348            user("current user"),
1349            assistant("current output"),
1350        ];
1351        let replacement = build_budgeted_replacement(
1352            &messages,
1353            &CompactRange {
1354                start: 0,
1355                end: 4,
1356                tokens_saved_estimate: 1,
1357            },
1358            "anchor",
1359            1,
1360            "missing-provider",
1361            &crate::provider::ProviderRegistry::default(),
1362        )
1363        .await;
1364
1365        assert!(is_compaction_summary(&replacement[0]));
1366        let records: Vec<_> = replacement
1367            .iter()
1368            .flat_map(|message| &message.parts)
1369            .filter_map(|part| match part {
1370                MessagePart::ContextRecord(record) => Some(record),
1371                _ => None,
1372            })
1373            .collect();
1374        assert_eq!(records.len(), 2);
1375        assert_eq!(records[0].key(), "session.goal");
1376        assert_eq!(records[0].revision(), 2);
1377        assert_eq!(records[1].key(), "session.workspace");
1378        assert!(records[1].body().is_tombstone());
1379        assert_eq!(
1380            replacement
1381                .iter()
1382                .filter(|message| message.role == MessageRole::User)
1383                .map(Message::text_concat)
1384                .collect::<Vec<_>>(),
1385            ["first user", "current user"]
1386        );
1387        assert!(
1388            !replacement
1389                .iter()
1390                .any(|message| message.role == MessageRole::Assistant)
1391        );
1392    }
1393
1394    #[tokio::test]
1395    async fn turn_rewrite_compacts_oversized_tool_output_without_dropping_recent_users() {
1396        let first = TurnId::now();
1397        let current = TurnId::now();
1398        let messages = vec![
1399            Message::user_text(first, "first user"),
1400            assistant_with_tool_use(
1401                &"calling tool".repeat(2_000),
1402                "fs.read",
1403                serde_json::json!({"path": "/tmp/example"}),
1404            ),
1405            tool_result("call_test", &"tool output".repeat(8_000), false),
1406            Message::user_text(current, "current user"),
1407        ];
1408
1409        assert!(find_compact_range(&messages, 500).is_none());
1410        let (replacement, rewritten_count) = build_budgeted_turn_rewrite(
1411            &messages,
1412            500,
1413            "missing-provider",
1414            &crate::provider::ProviderRegistry::default(),
1415        )
1416        .await;
1417
1418        assert_eq!(rewritten_count, 2);
1419        assert!(estimate_tokens_for_messages(&replacement) <= 500);
1420        let users: Vec<String> = replacement
1421            .iter()
1422            .filter(|message| message.role == MessageRole::User)
1423            .map(Message::text_concat)
1424            .collect();
1425        assert_eq!(users, vec!["first user", "current user"]);
1426        assert!(replacement.iter().any(|message| {
1427            message
1428                .text_concat()
1429                .contains("omitted 2 oversized assistant/system/tool messages")
1430        }));
1431        assert!(!replacement.iter().any(|message| {
1432            message.parts.iter().any(|part| {
1433                matches!(
1434                    part,
1435                    MessagePart::ToolUse { .. } | MessagePart::ToolResult { .. }
1436                )
1437            })
1438        }));
1439    }
1440
1441    #[test]
1442    fn user_turn_ranges_ignore_misanchored_turn_ids() {
1443        let first = TurnId::now();
1444        let second = TurnId::now();
1445        let messages = vec![
1446            Message::user_text(first.clone(), "u1"),
1447            Message::assistant_text(second.clone(), "a1"),
1448            Message::user_text(second, "u2"),
1449            Message::assistant_text(first, "a2"),
1450        ];
1451        assert_eq!(user_turn_ranges(&messages), vec![(0, 2), (2, 4)]);
1452    }
1453
1454    #[test]
1455    fn summary_instructions_keep_decisions_before_next_move() {
1456        let objective = SUMMARY_INSTRUCTIONS
1457            .find("## Objective")
1458            .expect("objective");
1459        let decisions = SUMMARY_INSTRUCTIONS
1460            .find("## Decisions")
1461            .expect("decisions");
1462        let next_move = SUMMARY_INSTRUCTIONS
1463            .find("## Next Move")
1464            .expect("next move");
1465        assert!(objective < decisions);
1466        assert!(decisions < next_move);
1467    }
1468
1469    #[test]
1470    fn estimate_scales_with_char_length() {
1471        let short = user("hi");
1472        let long = user(&"x".repeat(3500));
1473        assert!(estimate_tokens_for_message(&long) > estimate_tokens_for_message(&short) * 100);
1474    }
1475
1476    #[test]
1477    fn find_compact_returns_none_when_under_budget() {
1478        let msgs = vec![user("a"), assistant("b"), user("c"), assistant("d")];
1479        assert!(find_compact_range(&msgs, 1000).is_none());
1480    }
1481
1482    #[test]
1483    fn find_compact_returns_none_for_short_history() {
1484        let msgs = vec![user(&"x".repeat(9000))];
1485        assert!(find_compact_range(&msgs, 100).is_none());
1486    }
1487
1488    #[test]
1489    fn find_kth_recent_user_handles_exact_excess_and_mixed_history() {
1490        let exact = vec![
1491            user("u0"),
1492            assistant("a0"),
1493            system("s0"),
1494            tool_result("call-0", "result", false),
1495            user("u1"),
1496            assistant("a1"),
1497            user("u2"),
1498            system("s1"),
1499            user("u3"),
1500            assistant("a3"),
1501            user("u4"),
1502        ];
1503        // With exactly five users, the fifth recent user is the first message.
1504        assert_eq!(find_kth_recent_user(&exact, KEEP_RECENT_USER_TURNS), 0);
1505
1506        let mut excess = exact.clone();
1507        excess.push(user("u5"));
1508        assert_eq!(find_kth_recent_user(&excess, KEEP_RECENT_USER_TURNS), 4);
1509
1510        let too_few = vec![user("a"), assistant("b"), assistant("c")];
1511        assert_eq!(find_kth_recent_user(&too_few, KEEP_RECENT_USER_TURNS), 0);
1512    }
1513
1514    #[test]
1515    fn find_compact_range_preserves_minimum_recent_messages_without_anchor() {
1516        let mut msgs = vec![system("head")];
1517        msgs.extend((0..25).map(|index| assistant(&format!("old {index}"))));
1518        msgs.extend(
1519            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1520        );
1521        let range = find_compact_range(&msgs, 1).expect("range");
1522        assert_eq!(range.start, 0);
1523        assert_eq!(range.end, msgs.len() - KEEP_RECENT_MESSAGES);
1524    }
1525
1526    #[test]
1527    fn find_compact_range_keeps_tool_use_with_later_result() {
1528        let mut msgs = (0..11)
1529            .map(|index| assistant(&format!("old {index}")))
1530            .collect::<Vec<_>>();
1531        msgs.push(assistant_with_tool_use(
1532            "calling tool",
1533            "fs.read",
1534            serde_json::json!({"path": "/tmp/example"}),
1535        ));
1536        msgs.push(tool_result("call_test", "result", false));
1537        msgs.extend((0..9).map(|index| {
1538            if index % 2 == 0 {
1539                user(&format!("recent user {index}"))
1540            } else {
1541                assistant(&format!("recent assistant {index}"))
1542            }
1543        }));
1544
1545        let range = find_compact_range(&msgs, 1).expect("range");
1546        assert_eq!(range.end, 11);
1547        assert!(matches!(
1548            msgs[range.end].parts.as_slice(),
1549            [MessagePart::Text { .. }, MessagePart::ToolUse { id, .. }] if id == "call_test"
1550        ));
1551    }
1552
1553    #[test]
1554    fn find_compact_range_keeps_parallel_tool_batch_together() {
1555        let mut msgs = (0..10)
1556            .map(|index| assistant(&format!("old {index}")))
1557            .collect::<Vec<_>>();
1558        msgs.push(assistant_with_tool_uses(&["call_a", "call_b"]));
1559        msgs.push(tool_result("call_a", "first result", false));
1560        msgs.push(tool_result("call_b", "second result", false));
1561        msgs.extend((0..9).map(|index| {
1562            if index % 2 == 0 {
1563                user(&format!("recent user {index}"))
1564            } else {
1565                assistant(&format!("recent assistant {index}"))
1566            }
1567        }));
1568
1569        let range = find_compact_range(&msgs, 1).expect("range");
1570        assert_eq!(range.end, 10);
1571        assert_eq!(
1572            msgs[range.end]
1573                .parts
1574                .iter()
1575                .filter(|part| matches!(part, MessagePart::ToolUse { .. }))
1576                .count(),
1577            2
1578        );
1579    }
1580
1581    #[test]
1582    fn find_compact_range_keeps_boundary_after_closed_tool_batch() {
1583        let mut msgs = (0..9)
1584            .map(|index| assistant(&format!("old {index}")))
1585            .collect::<Vec<_>>();
1586        msgs.push(assistant_with_tool_uses(&["call_a", "call_b"]));
1587        msgs.push(Message {
1588            role: MessageRole::Tool,
1589            parts: vec![
1590                MessagePart::ToolResult {
1591                    tool_use_id: "call_a".into(),
1592                    content: "first result".into(),
1593                    is_error: false,
1594                },
1595                MessagePart::ToolResult {
1596                    tool_use_id: "call_b".into(),
1597                    content: "second result".into(),
1598                    is_error: false,
1599                },
1600            ],
1601            turn_id: TurnId::now(),
1602            origin: MessageOrigin::User,
1603        });
1604        msgs.push(assistant("batch complete"));
1605        msgs.extend((0..10).map(|index| {
1606            if index % 2 == 0 {
1607                user(&format!("recent user {index}"))
1608            } else {
1609                assistant(&format!("recent assistant {index}"))
1610            }
1611        }));
1612
1613        let range = find_compact_range(&msgs, 1).expect("range");
1614        assert_eq!(range.end, 12);
1615        assert_eq!(msgs[range.end].role, MessageRole::User);
1616    }
1617
1618    #[test]
1619    fn find_compact_range_preserves_minimum_window_for_large_tool_result() {
1620        let mut msgs = vec![user(&"h".repeat(500_000))];
1621        for index in 1..31 {
1622            if matches!(index, 20 | 22 | 24 | 26 | 28 | 30) {
1623                msgs.push(user(&"u".repeat(800)));
1624            } else {
1625                msgs.push(assistant(&"a".repeat(800)));
1626            }
1627        }
1628        msgs.push(tool_result("call-large", &"t".repeat(22_000), false));
1629
1630        let budget = 120_000;
1631        let minimum_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
1632        assert!(estimate_tokens_for_message(msgs.last().unwrap()) > minimum_recent_tokens);
1633
1634        let range = find_compact_range(&msgs, budget).expect("range");
1635        assert!(
1636            range.end <= msgs.len() - KEEP_RECENT_MESSAGES,
1637            "range was {range:?}"
1638        );
1639        assert!(msgs.len() - range.end >= KEEP_RECENT_MESSAGES);
1640        assert!(estimate_tokens_for_messages(&msgs[range.end..]) >= minimum_recent_tokens);
1641    }
1642
1643    #[test]
1644    fn find_compact_range_handles_four_to_twenty_one_message_histories() {
1645        for len in 4..=21 {
1646            let msgs = (0..len)
1647                .map(|_| user(&"x".repeat(5000)))
1648                .collect::<Vec<_>>();
1649            assert_eq!(
1650                find_compact_range(&msgs, 1).is_some(),
1651                len >= KEEP_RECENT_MESSAGES + 2,
1652                "len={len}"
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn find_compact_range_recent_users_limit_mixed_history() {
1659        let msgs = vec![
1660            system("head"),
1661            assistant("a0"),
1662            user("u0"),
1663            tool_result("call-0", "r0", false),
1664            assistant("a1"),
1665            user("u1"),
1666            assistant("a2"),
1667            tool_result("call-1", "r1", false),
1668            user("u2"),
1669            assistant("a3"),
1670            system("note"),
1671            user("u3"),
1672            tool_result("call-2", "r2", false),
1673            assistant("a4"),
1674            user("u4"),
1675            assistant("a5"),
1676            tool_result("call-3", "r3", false),
1677            user("u5"),
1678            assistant("a6"),
1679            system("tail"),
1680            assistant("a7"),
1681        ];
1682
1683        let range = find_compact_range(&msgs, 1).expect("range");
1684        assert_eq!(range.end, 5);
1685        assert_eq!(msgs[range.end].role, MessageRole::User);
1686    }
1687
1688    #[test]
1689    fn find_compact_range_preserves_recent_user_turns_after_anchor() {
1690        let mut msgs = vec![system("head"), compaction_summary("summary")];
1691        msgs.extend((0..6).flat_map(|index| {
1692            [
1693                user(&format!("user {index}")),
1694                assistant("assistant"),
1695                assistant("tool fragment"),
1696            ]
1697        }));
1698        msgs.extend((0..12).map(|_| assistant("recent fragment")));
1699        let range = find_compact_range(&msgs, 1).expect("range");
1700        assert_eq!(range.start, 1);
1701        assert_eq!(range.end, 5);
1702        assert_eq!(msgs[range.end].role, MessageRole::User);
1703    }
1704
1705    #[test]
1706    fn find_compact_range_returns_none_when_end_cannot_cover_two_messages() {
1707        let msgs = vec![
1708            system("head"),
1709            compaction_summary("summary"),
1710            assistant("tail"),
1711            user("tail"),
1712        ];
1713        assert!(find_compact_range(&msgs, 1).is_none());
1714    }
1715
1716    #[test]
1717    fn extract_anchor_removes_leading_compact_summary() {
1718        let messages = vec![compaction_summary("anchor"), user("new")];
1719        let (anchor, remaining) = extract_anchor(&messages).expect("anchor");
1720        assert_eq!(anchor, "anchor");
1721        assert_eq!(remaining, &messages[1..]);
1722    }
1723
1724    #[test]
1725    fn extract_anchor_returns_none_without_leading_summary() {
1726        let messages = vec![user("new")];
1727        assert!(extract_anchor(&messages).is_none());
1728    }
1729
1730    #[test]
1731    fn compact_messages_on_handle_replaces_range_in_place() {
1732        let mut messages = vec![system("head")];
1733        messages.extend((0..9).map(|index| assistant(&format!("old {index}"))));
1734        messages.extend(
1735            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(4000))]),
1736        );
1737        messages.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1738        messages.push(user("tail"));
1739        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1740            std::sync::Arc::new(std::sync::Mutex::new(messages));
1741        // The recent-message and recent-user limits meet at the fifth recent user.
1742        // The compacted prefix is replaced by one summary while the tail remains.
1743        let result = compact_messages_on_handle(&handle, "gist".into(), 100);
1744        let result = result.expect("should compact");
1745        assert!(result.after_tokens < result.before_tokens);
1746        let msgs = handle.lock().unwrap();
1747        assert_eq!(result.compacted_start, 0);
1748        assert_eq!(result.compacted_end, 13);
1749        assert!(is_compaction_summary(&msgs[0]));
1750        assert_eq!(msgs.last().unwrap().text_concat(), "tail");
1751    }
1752
1753    #[test]
1754    fn compact_messages_on_handle_none_when_under_budget() {
1755        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1756            std::sync::Arc::new(std::sync::Mutex::new(vec![user("short")]));
1757        assert!(compact_messages_on_handle(&handle, "g".into(), 100_000).is_none());
1758    }
1759
1760    #[test]
1761    fn compact_messages_on_handle_none_when_summary_would_not_shrink() {
1762        // 4 messages, all tiny → find_compact_range returns a range but the
1763        // summary message itself is comparable in size, so after >= before.
1764        // Construct a case where find_compact_range returns Some but shrink
1765        // check rejects it: make the range cover near-empty messages so the
1766        // summary overhead exceeds the savings.
1767        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
1768            std::sync::Arc::new(std::sync::Mutex::new(vec![
1769                system("h"),
1770                user("."),
1771                assistant("."),
1772                user("."),
1773                assistant("."),
1774                user("t"),
1775            ]));
1776        // budget=1 forces a range, but messages are so small the summary won't help
1777        let result = compact_messages_on_handle(&handle, "x".into(), 1);
1778        // Either no range found (len 6 but tiny), or shrink rejected.
1779        // The key invariant: handle is unchanged if None.
1780        let before_len = handle.lock().unwrap().len();
1781        if result.is_none() {
1782            assert_eq!(handle.lock().unwrap().len(), before_len);
1783        }
1784    }
1785
1786    #[test]
1787    fn replace_range_puts_summary_system_message_in_place() {
1788        let msgs = vec![
1789            system("head"),
1790            user("m1"),
1791            assistant("m2"),
1792            user("m3"),
1793            assistant("m4"),
1794            user("tail"),
1795        ];
1796        let range = CompactRange {
1797            start: 1,
1798            end: 5,
1799            tokens_saved_estimate: 100,
1800        };
1801        let out = replace_range_with_summary(
1802            &msgs,
1803            &range,
1804            "gist: talked about m1..m4".into(),
1805            TurnId::now(),
1806        );
1807        assert_eq!(out.len(), 2, "summary + tail");
1808        assert_eq!(out[0].role, MessageRole::System);
1809        assert!(out[0].text_concat().contains("gist: talked about"));
1810        assert!(matches!(
1811            out[0].parts.as_slice(),
1812            [MessagePart::CompactSummary {
1813                seq_start: 1,
1814                seq_end: 4,
1815                count: 4,
1816                ..
1817            }]
1818        ));
1819        assert_eq!(out[1].role, MessageRole::User);
1820        assert_eq!(out[1].text_concat(), "tail");
1821    }
1822
1823    #[test]
1824    fn find_compact_range_anchors_on_latest_structured_summary() {
1825        let mut msgs = vec![
1826            system("head"),
1827            Message::system_compact_summary(TurnId::now(), "old", 0, 1, 2),
1828        ];
1829        msgs.extend(
1830            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1831        );
1832        msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
1833        let range = find_compact_range(&msgs, 1).expect("range");
1834        assert_eq!(range.start, 1);
1835        assert_eq!(range.end, 4);
1836    }
1837
1838    fn assistant_with_tool_use(text: &str, tool_name: &str, input: serde_json::Value) -> Message {
1839        Message {
1840            role: MessageRole::Assistant,
1841            parts: vec![
1842                MessagePart::Text { text: text.into() },
1843                MessagePart::ToolUse {
1844                    id: "call_test".into(),
1845                    name: tool_name.into(),
1846                    input,
1847                    intent: None,
1848                },
1849            ],
1850            turn_id: TurnId::now(),
1851            origin: MessageOrigin::User,
1852        }
1853    }
1854
1855    fn assistant_with_tool_uses(ids: &[&str]) -> Message {
1856        Message {
1857            role: MessageRole::Assistant,
1858            parts: ids
1859                .iter()
1860                .map(|id| MessagePart::ToolUse {
1861                    id: (*id).into(),
1862                    name: "fs.read".into(),
1863                    input: serde_json::json!({"path": format!("/tmp/{id}")}),
1864                    intent: None,
1865                })
1866                .collect(),
1867            turn_id: TurnId::now(),
1868            origin: MessageOrigin::User,
1869        }
1870    }
1871
1872    fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
1873        Message {
1874            role: MessageRole::Tool,
1875            parts: vec![MessagePart::ToolResult {
1876                tool_use_id: id.into(),
1877                content: content.into(),
1878                is_error,
1879            }],
1880            turn_id: TurnId::now(),
1881            origin: MessageOrigin::User,
1882        }
1883    }
1884
1885    fn thinking(text: &str) -> Message {
1886        Message {
1887            role: MessageRole::Assistant,
1888            parts: vec![
1889                MessagePart::Thinking {
1890                    thinking: text.into(),
1891                    signature: None,
1892                },
1893                MessagePart::Text {
1894                    text: "after thinking".into(),
1895                },
1896            ],
1897            turn_id: TurnId::now(),
1898            origin: MessageOrigin::User,
1899        }
1900    }
1901
1902    #[test]
1903    fn format_slice_for_summary_includes_tool_use() {
1904        let slice = vec![
1905            user("read the file"),
1906            assistant_with_tool_use(
1907                "let me check",
1908                "fs.read",
1909                serde_json::json!({"path": "/tmp/foo.rs"}),
1910            ),
1911            tool_result("call_test", "fn main() {}", false),
1912        ];
1913        let out = format_slice_for_summary(&slice);
1914        assert!(out.contains("fs.read"), "missing tool name: {out}");
1915        assert!(out.contains("/tmp/foo.rs"), "missing tool input: {out}");
1916        assert!(
1917            out.contains("fn main()"),
1918            "missing tool_result content: {out}"
1919        );
1920        assert!(out.contains("tool_call"), "missing tool_call marker: {out}");
1921        assert!(
1922            out.contains("tool_result"),
1923            "missing tool_result marker: {out}"
1924        );
1925    }
1926
1927    #[test]
1928    fn format_slice_for_summary_includes_thinking() {
1929        let slice = vec![thinking("I should consider the edge case")];
1930        let out = format_slice_for_summary(&slice);
1931        assert!(out.contains("thinking"), "missing thinking marker: {out}");
1932        assert!(out.contains("edge case"), "missing thinking content: {out}");
1933    }
1934
1935    #[test]
1936    fn format_slice_for_summary_marks_error_tool_results() {
1937        let slice = vec![tool_result("call_1", "permission denied", true)];
1938        let out = format_slice_for_summary(&slice);
1939        assert!(out.contains("ERROR"), "missing ERROR marker: {out}");
1940    }
1941
1942    #[test]
1943    fn format_slice_for_summary_truncates_long_tool_input() {
1944        let long_input = serde_json::json!({"content": "x".repeat(5000)});
1945        let slice = vec![assistant_with_tool_use("check", "fs.write", long_input)];
1946        let out = format_slice_for_summary(&slice);
1947        let tool_call_line = out
1948            .lines()
1949            .find(|l| l.contains("tool_call"))
1950            .unwrap_or_else(|| panic!("no tool_call line in {out}"));
1951        assert!(
1952            tool_call_line.chars().count() < 2200,
1953            "tool_call line not truncated: {tool_call_line}"
1954        );
1955    }
1956
1957    fn compaction_summary(text: &str) -> Message {
1958        Message::system_compact_summary(TurnId::now(), text, 1, 5, 5)
1959    }
1960
1961    #[test]
1962    fn is_compaction_summary_detects_structured_variant() {
1963        assert!(is_compaction_summary(&compaction_summary("gist")));
1964        assert!(!is_compaction_summary(&system("plain system msg")));
1965        assert!(!is_compaction_summary(&user("user msg")));
1966    }
1967
1968    #[test]
1969    fn find_compact_range_spans_across_compaction_summaries() {
1970        let mut msgs = vec![
1971            system("head"),
1972            user(&"x".repeat(3000)),
1973            assistant(&"y".repeat(3000)),
1974            user(&"z".repeat(3000)),
1975            compaction_summary("first compaction summary"),
1976        ];
1977        msgs.extend(
1978            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(3000))]),
1979        );
1980        msgs.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1981        let range = find_compact_range(&msgs, 500).expect("expected range across summary");
1982        assert_eq!(
1983            range.start, 4,
1984            "range should anchor at the structured summary"
1985        );
1986        assert!(
1987            range.end > 4,
1988            "range should include later work, got {range:?}"
1989        );
1990        assert!(
1991            range.end - range.start >= 3,
1992            "range must cover >= 3 msgs, got {}",
1993            range.end - range.start
1994        );
1995    }
1996
1997    #[test]
1998    fn find_compact_starts_from_summary() {
1999        let mut msgs = vec![user("a"), assistant("b"), compaction_summary("summary 1")];
2000        msgs.extend(
2001            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
2002        );
2003        msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
2004        let range = find_compact_range(&msgs, 10).expect("expected range");
2005        assert_eq!(
2006            range.start, 2,
2007            "should start from the compact summary anchor"
2008        );
2009        assert_eq!(range.end, 5, "the fifth recent user is retained");
2010    }
2011
2012    #[test]
2013    fn find_compact_range_includes_older_compaction_summaries() {
2014        let mut msgs = vec![compaction_summary("summary 0")];
2015        msgs.extend((0..6).flat_map(|index| {
2016            [
2017                user(&format!("old user {index}")),
2018                assistant(&"x".repeat(2000)),
2019            ]
2020        }));
2021        msgs.push(compaction_summary("summary 1"));
2022        msgs.extend((0..6).flat_map(|index| {
2023            [
2024                user(&format!("new user {index}")),
2025                assistant(&"z".repeat(2000)),
2026            ]
2027        }));
2028        msgs.extend((0..10).map(|index| assistant(&format!("tail {index}"))));
2029        let range = find_compact_range(&msgs, 500).expect("expected range");
2030        assert_eq!(range.start, 13, "should compact from the latest summary");
2031        assert!(
2032            range.end > range.start,
2033            "should include work after the latest summary"
2034        );
2035    }
2036
2037    #[test]
2038    fn compacted_message_tokens_detects_growth() {
2039        let msgs = vec![compaction_summary("summary 0"), user("a"), assistant("b")];
2040        let range = CompactRange {
2041            start: 1,
2042            end: 3,
2043            tokens_saved_estimate: 0,
2044        };
2045        let before = estimate_tokens_for_messages(&msgs);
2046        let after = estimate_compacted_message_tokens(
2047            &msgs,
2048            &range,
2049            "a very long summary that expands the transcript a lot",
2050        );
2051        assert!(after > before, "expected growth to be detectable");
2052    }
2053
2054    #[test]
2055    fn find_compact_starts_from_zero_without_summary() {
2056        let mut msgs = (0..26)
2057            .map(|index| assistant(&format!("old {index}")))
2058            .collect::<Vec<_>>();
2059        msgs.extend(
2060            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
2061        );
2062        let range = find_compact_range(&msgs, 10).expect("expected range");
2063        assert_eq!(range.start, 0, "should start from 0 without summary");
2064        assert_eq!(range.end, 28, "the recent-message limit is retained");
2065    }
2066
2067    #[test]
2068    fn filter_orphan_tool_messages_removes_orphan_results() {
2069        use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2070        let turn = TurnId::now();
2071        let msgs = vec![
2072            Message {
2073                role: MessageRole::Tool,
2074                parts: vec![MessagePart::ToolResult {
2075                    tool_use_id: "orphan".into(),
2076                    content: "no matching use".into(),
2077                    is_error: false,
2078                }],
2079                turn_id: turn.clone(),
2080                origin: MessageOrigin::User,
2081            },
2082            Message {
2083                role: MessageRole::Assistant,
2084                parts: vec![MessagePart::ToolUse {
2085                    id: "call_1".into(),
2086                    name: "fs.read".into(),
2087                    input: serde_json::json!({}),
2088                    intent: None,
2089                }],
2090                turn_id: turn.clone(),
2091                origin: MessageOrigin::User,
2092            },
2093            Message {
2094                role: MessageRole::Tool,
2095                parts: vec![MessagePart::ToolResult {
2096                    tool_use_id: "call_1".into(),
2097                    content: "ok".into(),
2098                    is_error: false,
2099                }],
2100                turn_id: turn,
2101                origin: MessageOrigin::User,
2102            },
2103        ];
2104        let mut filtered = msgs;
2105        filter_orphan_tool_messages(&mut filtered);
2106        assert_eq!(filtered.len(), 2, "orphan result should be removed");
2107    }
2108
2109    #[test]
2110    fn filter_orphan_tool_parts_preserves_valid_mixed_message_content() {
2111        use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2112        let turn = TurnId::now();
2113        let mut messages = vec![
2114            Message {
2115                role: MessageRole::Assistant,
2116                parts: vec![
2117                    MessagePart::Text {
2118                        text: "keep assistant text".into(),
2119                    },
2120                    MessagePart::ToolUse {
2121                        id: "valid".into(),
2122                        name: "fs.read".into(),
2123                        input: serde_json::json!({}),
2124                        intent: None,
2125                    },
2126                    MessagePart::ToolUse {
2127                        id: "orphan-use".into(),
2128                        name: "fs.read".into(),
2129                        input: serde_json::json!({}),
2130                        intent: None,
2131                    },
2132                ],
2133                turn_id: turn.clone(),
2134                origin: MessageOrigin::User,
2135            },
2136            Message {
2137                role: MessageRole::Tool,
2138                parts: vec![
2139                    MessagePart::Text {
2140                        text: "keep tool text".into(),
2141                    },
2142                    MessagePart::ToolResult {
2143                        tool_use_id: "valid".into(),
2144                        content: "ok".into(),
2145                        is_error: false,
2146                    },
2147                    MessagePart::ToolResult {
2148                        tool_use_id: "orphan-result".into(),
2149                        content: "drop".into(),
2150                        is_error: false,
2151                    },
2152                ],
2153                turn_id: turn,
2154                origin: MessageOrigin::User,
2155            },
2156        ];
2157
2158        filter_orphan_tool_messages(&mut messages);
2159
2160        assert_eq!(messages.len(), 2);
2161        assert!(
2162            matches!(&messages[0].parts[..], [MessagePart::Text { .. }, MessagePart::ToolUse { id, .. }] if id == "valid")
2163        );
2164        assert!(
2165            matches!(&messages[1].parts[..], [MessagePart::Text { .. }, MessagePart::ToolResult { tool_use_id, .. }] if tool_use_id == "valid")
2166        );
2167    }
2168}