Skip to main content

harn_vm/orchestration/
compaction.rs

1//! Auto-compaction — transcript size management strategies.
2
3use std::collections::BTreeMap;
4use std::rc::Rc;
5
6use crate::llm::{vm_call_llm_full, vm_value_to_json};
7use crate::value::{VmError, VmValue};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum CompactStrategy {
11    Llm,
12    Truncate,
13    Custom,
14    ObservationMask,
15}
16
17pub fn parse_compact_strategy(value: &str) -> Result<CompactStrategy, VmError> {
18    match value {
19        "llm" => Ok(CompactStrategy::Llm),
20        "truncate" => Ok(CompactStrategy::Truncate),
21        "custom" => Ok(CompactStrategy::Custom),
22        "observation_mask" => Ok(CompactStrategy::ObservationMask),
23        other => Err(VmError::Runtime(format!(
24            "unknown compact_strategy '{other}' (expected 'llm', 'truncate', 'custom', or 'observation_mask')"
25        ))),
26    }
27}
28
29pub fn compact_strategy_name(strategy: &CompactStrategy) -> &'static str {
30    match strategy {
31        CompactStrategy::Llm => "llm",
32        CompactStrategy::Truncate => "truncate",
33        CompactStrategy::Custom => "custom",
34        CompactStrategy::ObservationMask => "observation_mask",
35    }
36}
37
38/// Configuration for automatic transcript compaction in agent loops.
39///
40/// Two-tier compaction:
41///   Tier 1 (`token_threshold` / `compact_strategy`): lightweight, deterministic
42///     observation masking that fires early. Masks verbose tool results while
43///     preserving assistant prose and error output.
44///   Tier 2 (`hard_limit_tokens` / `hard_limit_strategy`): aggressive LLM-powered
45///     summarization that fires when tier-1 alone isn't enough, typically as the
46///     transcript approaches the model's actual context window.
47#[derive(Clone, Debug)]
48pub struct AutoCompactConfig {
49    /// Number of earliest messages to keep verbatim before the compacted
50    /// summary. The system prompt is not part of this list and is always
51    /// preserved separately by the caller.
52    pub keep_first: usize,
53    /// Tier-1 threshold: estimated tokens before lightweight compaction.
54    pub token_threshold: usize,
55    /// Maximum character length for a single tool result before microcompaction.
56    pub tool_output_max_chars: usize,
57    /// Number of recent messages to keep during compaction.
58    pub keep_last: usize,
59    /// Tier-1 strategy (default: ObservationMask).
60    pub compact_strategy: CompactStrategy,
61    /// Tier-2 threshold: fires when tier-1 result still exceeds this.
62    /// Typically set to ~75% of the model's actual context window.
63    /// When `None`, tier-2 is disabled.
64    pub hard_limit_tokens: Option<usize>,
65    /// Tier-2 strategy (default: Llm).
66    pub hard_limit_strategy: CompactStrategy,
67    /// Optional Harn callback used when a strategy is `custom`.
68    pub custom_compactor: Option<VmValue>,
69    /// Optional callback for domain-specific per-message masking during
70    /// observation mask compaction. Called with a list of archived messages,
71    /// returns a list of `Option<String>` — `Some(masked)` to override the
72    /// default mask for that message, `None` to use the default.
73    /// This lets the host (e.g. an IDE or cloud runner) inject AST outlines,
74    /// file summaries, etc. without putting language-specific logic in Harn.
75    pub mask_callback: Option<VmValue>,
76    /// Optional callback for per-tool-result compression. Called with
77    /// `{tool_name, output, max_chars}` and returns compressed output string.
78    /// When set, used INSTEAD of the built-in `microcompact_tool_output`.
79    /// This allows the pipeline to use LLM-based compression rather than
80    /// keyword heuristics.
81    pub compress_callback: Option<VmValue>,
82    /// Optional prompt-template asset path used when LLM compaction is
83    /// selected. The rendered template becomes the user message sent to
84    /// the summarizer.
85    pub summarize_prompt: Option<String>,
86    /// User-facing policy label for replay and observability. This can be
87    /// broader than the engine strategy, e.g. `hybrid` lowers to LLM
88    /// summarization plus truncate fallback.
89    pub policy_strategy: String,
90}
91
92impl Default for AutoCompactConfig {
93    fn default() -> Self {
94        Self {
95            keep_first: 0,
96            token_threshold: 48_000,
97            tool_output_max_chars: 16_000,
98            keep_last: 12,
99            compact_strategy: CompactStrategy::ObservationMask,
100            hard_limit_tokens: None,
101            hard_limit_strategy: CompactStrategy::Llm,
102            custom_compactor: None,
103            mask_callback: None,
104            compress_callback: None,
105            summarize_prompt: None,
106            policy_strategy: compact_strategy_name(&CompactStrategy::ObservationMask).to_string(),
107        }
108    }
109}
110
111/// Estimate token count from a list of JSON messages (chars / 4 heuristic).
112pub fn estimate_message_tokens(messages: &[serde_json::Value]) -> usize {
113    messages.iter().map(estimate_message_chars).sum::<usize>() / 4
114}
115
116fn estimate_message_chars(message: &serde_json::Value) -> usize {
117    let mut total = message
118        .get("content")
119        .map(estimate_content_chars)
120        .unwrap_or_default();
121    if let Some(reasoning) = message.get("reasoning") {
122        total += estimate_content_chars(reasoning);
123    }
124    if let Some(tool_calls) = message.get("tool_calls") {
125        total += estimate_content_chars(tool_calls);
126    }
127    total
128}
129
130fn estimate_content_chars(value: &serde_json::Value) -> usize {
131    match value {
132        serde_json::Value::String(text) => text.len(),
133        serde_json::Value::Array(items) => items.iter().map(estimate_content_chars).sum(),
134        serde_json::Value::Object(map) => map.values().map(estimate_content_chars).sum(),
135        serde_json::Value::Null => 0,
136        other => other.to_string().len(),
137    }
138}
139
140fn is_reasoning_or_tool_turn_message(message: &serde_json::Value) -> bool {
141    let role = message
142        .get("role")
143        .and_then(|value| value.as_str())
144        .unwrap_or_default();
145    role == "tool"
146        || message.get("tool_calls").is_some()
147        || message
148            .get("reasoning")
149            .map(|value| !value.is_null())
150            .unwrap_or(false)
151}
152
153fn find_prev_user_boundary(messages: &[serde_json::Value], start: usize) -> Option<usize> {
154    (0..=start)
155        .rev()
156        .find(|idx| messages[*idx].get("role").and_then(|value| value.as_str()) == Some("user"))
157}
158
159/// Microcompact a tool result: if it exceeds `max_chars`, keep the first and
160/// last portions with a snip marker in between.
161pub fn microcompact_tool_output(output: &str, max_chars: usize) -> String {
162    if output.len() <= max_chars || max_chars < 200 {
163        return output.to_string();
164    }
165    let diagnostic_lines = output
166        .lines()
167        .filter(|line| {
168            let trimmed = line.trim();
169            let lower = trimmed.to_lowercase();
170            let has_file_line = {
171                let bytes = trimmed.as_bytes();
172                let mut i = 0;
173                let mut found_colon = false;
174                while i < bytes.len() {
175                    if bytes[i] == b':' {
176                        found_colon = true;
177                        break;
178                    }
179                    i += 1;
180                }
181                found_colon && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()
182            };
183            let has_strong_keyword =
184                trimmed.contains("FAIL") || trimmed.contains("panic") || trimmed.contains("Panic");
185            let has_weak_keyword = trimmed.contains("error")
186                || trimmed.contains("undefined")
187                || trimmed.contains("expected")
188                || trimmed.contains("got")
189                || lower.contains("cannot find")
190                || lower.contains("not found")
191                || lower.contains("no such")
192                || lower.contains("unresolved")
193                || lower.contains("missing")
194                || lower.contains("declared but not used")
195                || lower.contains("unused")
196                || lower.contains("mismatch");
197            let positional = lower.contains(" error ")
198                || lower.starts_with("error:")
199                || lower.starts_with("warning:")
200                || lower.starts_with("note:")
201                || lower.contains("panic:");
202            has_strong_keyword || (has_file_line && has_weak_keyword) || positional
203        })
204        .take(32)
205        .collect::<Vec<_>>();
206    if !diagnostic_lines.is_empty() {
207        let diagnostics = diagnostic_lines.join("\n");
208        let budget = max_chars.saturating_sub(diagnostics.len() + 64);
209        let keep = budget / 2;
210        if keep >= 80 && output.len() > keep * 2 {
211            let head = snap_to_line_end(output, keep);
212            let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
213            return format!(
214                "{head}\n\n[diagnostic lines preserved]\n{diagnostics}\n\n[... output compacted ...]\n\n{tail}"
215            );
216        }
217    }
218    let keep = max_chars / 2;
219    let head = snap_to_line_end(output, keep);
220    let tail = snap_to_line_start(output, output.len().saturating_sub(keep));
221    let snipped = output.len().saturating_sub(head.len() + tail.len());
222    format!("{head}\n\n[... {snipped} characters snipped ...]\n\n{tail}")
223}
224
225/// Snap a byte offset to the nearest preceding line boundary (end of a complete line).
226/// Returns the substring from the start up to and including the last complete line
227/// that fits within `max_bytes`. Never cuts mid-line.
228fn snap_to_line_end(s: &str, max_bytes: usize) -> &str {
229    if max_bytes >= s.len() {
230        return s;
231    }
232    let search_end = s.floor_char_boundary(max_bytes);
233    match s[..search_end].rfind('\n') {
234        Some(pos) => &s[..pos + 1],
235        None => &s[..search_end], // single long line — fall back to char boundary
236    }
237}
238
239/// Snap a byte offset to the nearest following line boundary (start of a complete line).
240/// Returns the substring from the first complete line at or after `start_byte`.
241/// Never cuts mid-line.
242fn snap_to_line_start(s: &str, start_byte: usize) -> &str {
243    if start_byte == 0 {
244        return s;
245    }
246    let search_start = s.ceil_char_boundary(start_byte);
247    if search_start >= s.len() {
248        return "";
249    }
250    match s[search_start..].find('\n') {
251        Some(pos) => {
252            let line_start = search_start + pos + 1;
253            if line_start < s.len() {
254                &s[line_start..]
255            } else {
256                &s[search_start..]
257            }
258        }
259        None => &s[search_start..], // already at start of last line
260    }
261}
262
263fn format_compaction_messages(messages: &[serde_json::Value]) -> String {
264    messages
265        .iter()
266        .map(|msg| {
267            let role = msg
268                .get("role")
269                .and_then(|v| v.as_str())
270                .unwrap_or("user")
271                .to_uppercase();
272            let content = msg
273                .get("content")
274                .and_then(|v| v.as_str())
275                .unwrap_or_default();
276            format!("{role}: {content}")
277        })
278        .collect::<Vec<_>>()
279        .join("\n")
280}
281
282fn truncate_compaction_summary(
283    old_messages: &[serde_json::Value],
284    archived_count: usize,
285) -> String {
286    truncate_compaction_summary_with_context(old_messages, archived_count, false)
287}
288
289fn truncate_compaction_summary_with_context(
290    old_messages: &[serde_json::Value],
291    archived_count: usize,
292    is_llm_fallback: bool,
293) -> String {
294    let per_msg_limit = 500_usize;
295    let summary_parts: Vec<String> = old_messages
296        .iter()
297        .filter_map(|m| {
298            let role = m.get("role")?.as_str()?;
299            let content = m.get("content")?.as_str()?;
300            if content.is_empty() {
301                return None;
302            }
303            let truncated = if content.len() > per_msg_limit {
304                format!(
305                    "{}... [truncated from {} chars]",
306                    &content[..content.floor_char_boundary(per_msg_limit)],
307                    content.len()
308                )
309            } else {
310                content.to_string()
311            };
312            Some(format!("[{role}] {truncated}"))
313        })
314        .take(15)
315        .collect();
316    let header = if is_llm_fallback {
317        format!(
318            "[auto-compact fallback: LLM summarizer returned empty; {archived_count} older messages abbreviated to ~{per_msg_limit} chars each]"
319        )
320    } else {
321        format!("[auto-compacted {archived_count} older messages via truncate strategy]")
322    };
323    format!(
324        "{header}\n{}{}",
325        summary_parts.join("\n"),
326        if archived_count > 15 {
327            format!("\n... and {} more", archived_count - 15)
328        } else {
329            String::new()
330        }
331    )
332}
333
334fn compact_summary_text_from_value(value: &VmValue) -> Result<String, VmError> {
335    if let Some(map) = value.as_dict() {
336        if let Some(summary) = map.get("summary").or_else(|| map.get("text")) {
337            return Ok(summary.display());
338        }
339    }
340    match value {
341        VmValue::String(text) => Ok(text.to_string()),
342        VmValue::Nil => Ok(String::new()),
343        _ => serde_json::to_string_pretty(&vm_value_to_json(value))
344            .map_err(|e| VmError::Runtime(format!("custom compactor encode error: {e}"))),
345    }
346}
347
348async fn llm_compaction_summary(
349    old_messages: &[serde_json::Value],
350    archived_count: usize,
351    llm_opts: &crate::llm::api::LlmCallOptions,
352    summarize_prompt: Option<&str>,
353) -> Result<String, VmError> {
354    let mut compact_opts = llm_opts.clone();
355    let formatted = format_compaction_messages(old_messages);
356    compact_opts.system = None;
357    compact_opts.transcript_summary = None;
358    compact_opts.native_tools = None;
359    compact_opts.tool_choice = None;
360    compact_opts.output_format = crate::llm::api::OutputFormat::Text;
361    compact_opts.response_format = None;
362    compact_opts.json_schema = None;
363    compact_opts.output_schema = None;
364    let prompt = render_llm_compaction_prompt(summarize_prompt, &formatted, archived_count)?;
365    compact_opts.messages = vec![serde_json::json!({
366        "role": "user",
367        "content": prompt,
368    })];
369    let result = vm_call_llm_full(&compact_opts).await?;
370    let summary = result.text.trim();
371    if summary.is_empty() {
372        Ok(truncate_compaction_summary_with_context(
373            old_messages,
374            archived_count,
375            true,
376        ))
377    } else {
378        Ok(format!(
379            "[auto-compacted {archived_count} older messages]\n{summary}"
380        ))
381    }
382}
383
384fn render_llm_compaction_prompt(
385    summarize_prompt: Option<&str>,
386    formatted: &str,
387    archived_count: usize,
388) -> Result<String, VmError> {
389    let mut bindings = BTreeMap::new();
390    bindings.insert(
391        "formatted_messages".to_string(),
392        VmValue::String(Rc::from(formatted.to_string())),
393    );
394    bindings.insert(
395        "archived_count".to_string(),
396        VmValue::Int(archived_count as i64),
397    );
398    let Some(path) = summarize_prompt.filter(|path| !path.trim().is_empty()) else {
399        return crate::stdlib::template::render_stdlib_prompt_asset(
400            "orchestration/prompts/compaction_summary.harn.prompt",
401            Some(&bindings),
402        );
403    };
404
405    let asset = crate::stdlib::template::TemplateAsset::render_target(path)
406        .map_err(|error| VmError::Runtime(format!("compaction summarize_prompt: {error}")))?;
407    crate::stdlib::template::render_asset_result(&asset, Some(&bindings)).map_err(VmError::from)
408}
409
410async fn custom_compaction_summary(
411    old_messages: &[serde_json::Value],
412    archived_count: usize,
413    callback: &VmValue,
414) -> Result<String, VmError> {
415    let Some(VmValue::Closure(closure)) = Some(callback.clone()) else {
416        return Err(VmError::Runtime(
417            "compact_callback must be a closure when compact_strategy is 'custom'".to_string(),
418        ));
419    };
420    let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
421        VmError::Runtime(
422            "custom transcript compaction requires an async builtin VM context".to_string(),
423        )
424    })?;
425    let messages_vm = VmValue::List(Rc::new(
426        old_messages
427            .iter()
428            .map(crate::stdlib::json_to_vm_value)
429            .collect(),
430    ));
431    let result = vm.call_closure_pub(&closure, &[messages_vm]).await;
432    let summary = compact_summary_text_from_value(&result?)?;
433    if summary.trim().is_empty() {
434        Ok(truncate_compaction_summary(old_messages, archived_count))
435    } else {
436        Ok(format!(
437            "[auto-compacted {archived_count} older messages]\n{summary}"
438        ))
439    }
440}
441
442/// Check whether a tool-result string should be preserved verbatim during
443/// observation masking. Uses content length as the primary heuristic:
444/// short results (< 500 chars) are kept since they're typically error messages,
445/// status lines, or concise answers that are cheap to retain and risky to mask.
446/// Long results are masked to save context budget.
447fn content_should_preserve(content: &str) -> bool {
448    content.len() < 500
449}
450
451/// Default per-message masking for tool results.
452fn default_mask_tool_result(role: &str, content: &str) -> String {
453    let first_line = content.lines().next().unwrap_or(content);
454    let line_count = content.lines().count();
455    let char_count = content.len();
456    if line_count <= 3 {
457        format!("[{role}] {content}")
458    } else {
459        let preview = &first_line[..first_line.len().min(120)];
460        format!("[{role}] {preview}... [{line_count} lines, {char_count} chars masked]")
461    }
462}
463
464/// Deterministic observation-mask compaction.
465#[cfg(test)]
466pub(crate) fn observation_mask_compaction(
467    old_messages: &[serde_json::Value],
468    archived_count: usize,
469) -> String {
470    observation_mask_compaction_with_callback(old_messages, archived_count, None)
471}
472
473fn observation_mask_compaction_with_callback(
474    old_messages: &[serde_json::Value],
475    archived_count: usize,
476    mask_results: Option<&[Option<String>]>,
477) -> String {
478    let mut parts = Vec::new();
479    parts.push(format!(
480        "[auto-compacted {archived_count} older messages via observation masking]"
481    ));
482    for (idx, msg) in old_messages.iter().enumerate() {
483        let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
484        let content = msg
485            .get("content")
486            .and_then(|v| v.as_str())
487            .unwrap_or_default();
488        if content.is_empty() {
489            continue;
490        }
491        if role == "assistant" {
492            parts.push(format!("[assistant] {content}"));
493            continue;
494        }
495        if content_should_preserve(content) {
496            parts.push(format!("[{role}] {content}"));
497        } else if let Some(Some(custom)) = mask_results.and_then(|r| r.get(idx)) {
498            parts.push(custom.clone());
499        } else {
500            parts.push(default_mask_tool_result(role, content));
501        }
502    }
503    parts.join("\n")
504}
505
506/// Invoke the mask_callback to get per-message custom masks.
507async fn invoke_mask_callback(
508    callback: &VmValue,
509    old_messages: &[serde_json::Value],
510) -> Result<Vec<Option<String>>, VmError> {
511    let VmValue::Closure(closure) = callback.clone() else {
512        return Err(VmError::Runtime(
513            "mask_callback must be a closure".to_string(),
514        ));
515    };
516    let mut vm = crate::vm::clone_async_builtin_child_vm().ok_or_else(|| {
517        VmError::Runtime("mask_callback requires an async builtin VM context".to_string())
518    })?;
519    let messages_vm = VmValue::List(Rc::new(
520        old_messages
521            .iter()
522            .map(crate::stdlib::json_to_vm_value)
523            .collect(),
524    ));
525    let result = vm.call_closure_pub(&closure, &[messages_vm]).await?;
526    let list = match result {
527        VmValue::List(items) => items,
528        _ => return Ok(vec![None; old_messages.len()]),
529    };
530    Ok(list
531        .iter()
532        .map(|v| match v {
533            VmValue::String(s) => Some(s.to_string()),
534            VmValue::Nil => None,
535            _ => None,
536        })
537        .collect())
538}
539
540/// Apply a single compaction strategy to a list of archived messages.
541async fn apply_compaction_strategy(
542    strategy: &CompactStrategy,
543    old_messages: &[serde_json::Value],
544    archived_count: usize,
545    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
546    custom_compactor: Option<&VmValue>,
547    mask_callback: Option<&VmValue>,
548    summarize_prompt: Option<&str>,
549) -> Result<String, VmError> {
550    match strategy {
551        CompactStrategy::Truncate => Ok(truncate_compaction_summary(old_messages, archived_count)),
552        CompactStrategy::Llm => {
553            llm_compaction_summary(
554                old_messages,
555                archived_count,
556                llm_opts.ok_or_else(|| {
557                    VmError::Runtime(
558                        "LLM transcript compaction requires active LLM call options".to_string(),
559                    )
560                })?,
561                summarize_prompt,
562            )
563            .await
564        }
565        CompactStrategy::Custom => {
566            custom_compaction_summary(
567                old_messages,
568                archived_count,
569                custom_compactor.ok_or_else(|| {
570                    VmError::Runtime(
571                        "compact_callback is required when compact_strategy is 'custom'"
572                            .to_string(),
573                    )
574                })?,
575            )
576            .await
577        }
578        CompactStrategy::ObservationMask => {
579            let mask_results = if let Some(cb) = mask_callback {
580                Some(invoke_mask_callback(cb, old_messages).await?)
581            } else {
582                None
583            };
584            Ok(observation_mask_compaction_with_callback(
585                old_messages,
586                archived_count,
587                mask_results.as_deref(),
588            ))
589        }
590    }
591}
592
593/// Auto-compact a message list in place using two-tier compaction.
594pub(crate) async fn auto_compact_messages(
595    messages: &mut Vec<serde_json::Value>,
596    config: &AutoCompactConfig,
597    llm_opts: Option<&crate::llm::api::LlmCallOptions>,
598) -> Result<Option<String>, VmError> {
599    if config.token_threshold > 0 && estimate_message_tokens(messages) <= config.token_threshold {
600        return Ok(None);
601    }
602    if messages.len() <= config.keep_first.saturating_add(config.keep_last) {
603        return Ok(None);
604    }
605    let compact_start = config.keep_first.min(messages.len());
606    let original_split = messages.len().saturating_sub(config.keep_last);
607    let mut split_at = original_split;
608    // Snap back to a user-role boundary so the kept suffix begins at a clean
609    // turn. OpenAI-compatible APIs reject tool results orphaned from their
610    // assistant request, so splitting mid-turn corrupts the transcript.
611    while split_at > compact_start
612        && split_at < messages.len()
613        && messages[split_at]
614            .get("role")
615            .and_then(|r| r.as_str())
616            .is_none_or(|r| r != "user")
617    {
618        split_at -= 1;
619    }
620    // Fall back to the naive split (e.g. tool-heavy transcripts with the sole
621    // user message at index 0) rather than skipping compaction entirely.
622    if split_at == compact_start {
623        split_at = original_split;
624    }
625    if let Some(volatile_start) = messages[split_at..]
626        .iter()
627        .position(is_reasoning_or_tool_turn_message)
628        .map(|offset| split_at + offset)
629    {
630        if let Some(boundary) = volatile_start
631            .checked_sub(1)
632            .and_then(|idx| find_prev_user_boundary(messages, idx))
633            .filter(|boundary| *boundary > compact_start)
634        {
635            split_at = boundary;
636        }
637    }
638    if split_at <= compact_start {
639        return Ok(None);
640    }
641    let old_messages: Vec<_> = messages.drain(compact_start..split_at).collect();
642    let archived_count = old_messages.len();
643
644    let mut summary = apply_compaction_strategy(
645        &config.compact_strategy,
646        &old_messages,
647        archived_count,
648        llm_opts,
649        config.custom_compactor.as_ref(),
650        config.mask_callback.as_ref(),
651        config.summarize_prompt.as_deref(),
652    )
653    .await?;
654
655    if let Some(hard_limit) = config.hard_limit_tokens {
656        let summary_msg = serde_json::json!({"role": "user", "content": &summary});
657        let mut estimate_msgs = vec![summary_msg];
658        estimate_msgs.extend_from_slice(messages.as_slice());
659        let estimated = estimate_message_tokens(&estimate_msgs);
660        if estimated > hard_limit {
661            let tier1_as_messages = vec![serde_json::json!({
662                "role": "user",
663                "content": summary,
664            })];
665            summary = apply_compaction_strategy(
666                &config.hard_limit_strategy,
667                &tier1_as_messages,
668                archived_count,
669                llm_opts,
670                config.custom_compactor.as_ref(),
671                None,
672                config.summarize_prompt.as_deref(),
673            )
674            .await?;
675        }
676    }
677
678    messages.insert(
679        compact_start,
680        serde_json::json!({
681            "role": "user",
682            "content": summary,
683        }),
684    );
685    Ok(Some(summary))
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn microcompact_short_output_unchanged() {
694        let output = "line1\nline2\nline3\n";
695        assert_eq!(microcompact_tool_output(output, 1000), output);
696    }
697
698    #[test]
699    fn microcompact_snaps_to_line_boundaries() {
700        let lines: Vec<String> = (0..20)
701            .map(|i| format!("line {:02} content here", i))
702            .collect();
703        let output = lines.join("\n");
704        let result = microcompact_tool_output(&output, 200);
705        assert!(result.contains("[... "), "should have snip marker");
706        let parts: Vec<&str> = result.split("\n\n[... ").collect();
707        assert!(parts.len() >= 2, "should split at marker");
708        let head = parts[0];
709        for line in head.lines() {
710            assert!(
711                line.starts_with("line "),
712                "head line should be complete: {line}"
713            );
714        }
715    }
716
717    #[test]
718    fn microcompact_preserves_diagnostic_lines_with_line_boundaries() {
719        let mut lines = Vec::new();
720        for i in 0..50 {
721            lines.push(format!("verbose output line {i}"));
722        }
723        lines.push("src/main.rs:42: error: cannot find value".to_string());
724        for i in 50..100 {
725            lines.push(format!("verbose output line {i}"));
726        }
727        let output = lines.join("\n");
728        let result = microcompact_tool_output(&output, 600);
729        assert!(result.contains("cannot find value"), "diagnostic preserved");
730        assert!(
731            result.contains("[diagnostic lines preserved]"),
732            "has diagnostic marker"
733        );
734    }
735
736    #[test]
737    fn token_estimate_counts_structured_message_content() {
738        let text = "x".repeat(400);
739        let messages = vec![serde_json::json!({
740            "role": "user",
741            "content": [
742                {"type": "text", "text": text},
743                {"type": "input_text", "text": "tail"},
744            ],
745            "reasoning": {"text": "scratch"},
746            "tool_calls": [{
747                "id": "call_1",
748                "type": "function",
749                "function": {"name": "read", "arguments": "{\"path\":\"src/main.rs\"}"}
750            }],
751        })];
752
753        assert!(
754            estimate_message_tokens(&messages) >= 100,
755            "structured content must not count as zero"
756        );
757    }
758
759    #[test]
760    fn snap_to_line_end_finds_newline() {
761        let s = "line1\nline2\nline3\nline4\n";
762        let head = snap_to_line_end(s, 12);
763        assert!(head.ends_with('\n'), "should end at newline");
764        assert!(head.contains("line1"));
765    }
766
767    #[test]
768    fn snap_to_line_start_finds_newline() {
769        let s = "line1\nline2\nline3\nline4\n";
770        let tail = snap_to_line_start(s, 12);
771        assert!(
772            tail.starts_with("line"),
773            "should start at line boundary: {tail}"
774        );
775    }
776
777    #[test]
778    fn auto_compact_preserves_reasoning_tool_suffix() {
779        let mut messages = vec![
780            serde_json::json!({"role": "user", "content": "old task"}),
781            serde_json::json!({"role": "assistant", "content": "old reply"}),
782            serde_json::json!({"role": "user", "content": "new task"}),
783            serde_json::json!({
784                "role": "assistant",
785                "content": "",
786                "reasoning": "think first",
787                "tool_calls": [{
788                    "id": "call_1",
789                    "type": "function",
790                    "function": {"name": "read", "arguments": "{\"path\":\"foo.rs\"}"}
791                }],
792            }),
793            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "file"}),
794        ];
795        let config = AutoCompactConfig {
796            token_threshold: 1,
797            keep_last: 2,
798            ..Default::default()
799        };
800
801        let runtime = tokio::runtime::Builder::new_current_thread()
802            .enable_all()
803            .build()
804            .expect("runtime");
805        let summary = runtime
806            .block_on(auto_compact_messages(&mut messages, &config, None))
807            .expect("compaction succeeds");
808
809        assert!(summary.is_some());
810        assert_eq!(messages[1]["role"], "user");
811        assert_eq!(messages[2]["role"], "assistant");
812        assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1");
813        assert_eq!(messages[3]["role"], "tool");
814        assert_eq!(messages[3]["tool_call_id"], "call_1");
815    }
816}