Skip to main content

harn_vm/orchestration/
compaction.rs

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