Skip to main content

vtcode_core/compaction/
auto.rs

1//! Unified automatic compaction orchestration.
2//!
3//! This is the single entry point used by every runtime (the binary unified
4//! runloop and the `vtcode-core` `AgentRunner`) for *automatic* context
5//! compaction: when token pressure crosses the configured threshold, the
6//! conversation trace is compressed (provider-native or local LLM summary),
7//! a recoverable history artifact is written, and a session memory envelope is
8//! built and injected so the model keeps its conversational continuity.
9
10use std::path::Path;
11
12use anyhow::Result;
13
14use crate::compaction::memory_envelope::{
15    MemoryEnvelopePersistence, MemoryEnvelopePlacement, SessionMemoryEnvelopeUpdate,
16    dedup_repeated_file_reads_for_local_compaction, effective_compaction_threshold_with_reserve,
17    effective_context_budget, persist_memory_envelope_async_with_update, strip_existing_memory_envelope,
18};
19use crate::compaction::two_pass::fingerprint_prefix;
20use crate::compaction::{
21    CompactionConfig, CompactionParentContext, CompactionStrategy, ManualCompactionOptions, SUPPRESS_NONE,
22    build_local_compacted_history, build_summary_prompt, classify_suppress_reason,
23    compact_history_manual_with_parent_context, compacted_history_shrinks, manual_compaction_strategy,
24};
25use crate::exec::events::CompactionMode;
26use crate::llm::{
27    collect_single_response,
28    provider::{LLMProvider, LLMRequest, Message, ToolChoice},
29};
30use vtcode_config::loader::VTCodeConfig;
31
32/// Result of a successful automatic compaction pass.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct AutoCompactionOutcome {
35    pub original_len: usize,
36    pub compacted_len: usize,
37    pub mode: CompactionMode,
38    pub history_artifact_path: Option<String>,
39    /// The session memory envelope produced during compaction.
40    /// Used by the caller to write persistent checkpoints.
41    pub envelope: Option<crate::compaction::memory_envelope::SessionMemoryEnvelope>,
42}
43
44/// Inputs for [`auto_compact_messages`].
45pub struct AutoCompactionInput<'a> {
46    pub provider: &'a dyn LLMProvider,
47    pub model: &'a str,
48    pub session_id: &'a str,
49    pub workspace_root: &'a Path,
50    pub vt_cfg: Option<&'a VTCodeConfig>,
51    /// Current estimated token usage of the live history (before compaction).
52    pub current_token_usage: usize,
53    /// Output limit of the next request, excluding prompt/history overhead.
54    pub reserved_output_tokens: usize,
55    /// Files touched so far this session (enrich the memory envelope).
56    pub touched_files: &'a [String],
57    /// Engine configuration (thresholds, retention, summary prompt overrides).
58    pub engine_cfg: CompactionConfig,
59    /// Manual compaction options (instructions, max output, reasoning, verbosity).
60    pub manual_options: ManualCompactionOptions,
61    /// Where to place the injected memory envelope in the compacted history.
62    pub placement: MemoryEnvelopePlacement,
63    /// Prefire two-pass state (background NOTE₁ cache).
64    pub prefire: Option<&'a crate::compaction::PrefireState>,
65    /// Mutable auto-compaction suppression state. `SUPPRESS_NONE` allows
66    /// compaction; any other value gates automatic compaction until cleared
67    /// by success, model switch, or explicit `/compact`.
68    pub auto_compact_suppressed: &'a mut u8,
69    /// Force a pending soft-threshold compaction at the next outer turn
70    /// boundary. This is set only by the runloop boundary, never by a tool
71    /// loop, so no hidden model call is introduced mid-turn.
72    pub force_compaction: bool,
73    /// Live steering state to snapshot with the compaction envelope.
74    pub steering_update: Option<&'a SessionMemoryEnvelopeUpdate>,
75    /// Parent segment prefix for cache-safe forking (system prompt + ordered
76    /// tools from the live request envelope). When supplied, the local-summary
77    /// fork reuses the parent's cached prefix instead of re-paying full input
78    /// cost. Native strategies ignore it.
79    pub parent_context: Option<CompactionParentContext>,
80}
81
82/// Compress `history` in place when automatic compaction should fire.
83///
84/// Returns `None` when compaction is disabled, the history is below the trigger
85/// threshold, or the engine produced no change. On a successful pass the
86/// compacted history (with envelope injected) replaces `history` and an
87/// [`AutoCompactionOutcome`] is returned; the caller is responsible for
88/// emitting the `thread.compact_boundary` event and resetting token accounting.
89pub async fn auto_compact_messages(
90    input: AutoCompactionInput<'_>,
91    history: &mut Vec<Message>,
92) -> Result<Option<AutoCompactionOutcome>> {
93    let AutoCompactionInput {
94        provider,
95        model,
96        session_id,
97        workspace_root,
98        vt_cfg,
99        current_token_usage,
100        reserved_output_tokens,
101        touched_files,
102        engine_cfg,
103        manual_options,
104        placement,
105        prefire,
106        auto_compact_suppressed,
107        force_compaction,
108        steering_update,
109        parent_context,
110    } = input;
111
112    if !vt_cfg.is_some_and(|cfg| cfg.agent.harness.auto_compaction_enabled) {
113        return Ok(None);
114    }
115
116    let Some(threshold) = effective_compaction_threshold_with_reserve(vt_cfg, provider, model, reserved_output_tokens)
117    else {
118        return Ok(None);
119    };
120
121    // Use the same provider/session intersection as the trigger and preflight
122    // paths. This bounds local input and output, while native output remains
123    // provider-canonical and is not rewritten by the session budget.
124    let context_budget = match effective_context_budget(vt_cfg, provider, model) {
125        0 => None,
126        budget => Some(budget),
127    };
128    // Per-route triggers can only fire *earlier* (fail-safe); the default
129    // ratio preserves the absolute-tokens trigger unchanged.
130    let window = context_budget.unwrap_or(0);
131    let threshold = crate::compaction::CompactionRoutePolicy::resolve(provider.name(), model)
132        .apply_threshold_cap(threshold, window);
133    if current_token_usage < threshold && !force_compaction {
134        return Ok(None);
135    }
136
137    if *auto_compact_suppressed != SUPPRESS_NONE {
138        return Ok(None);
139    }
140
141    let mut compaction_input = history.clone();
142    strip_existing_memory_envelope(&mut compaction_input);
143    let original_history = compaction_input.clone();
144
145    let compaction_result: Result<Option<AutoCompactionOutcome>> = async {
146        // Try two-pass with prefire cache before falling back to single-pass.
147        if let Some(prefire_state) = prefire {
148            if let Some(mut compacted) = try_two_pass_with_prefire(
149                prefire_state,
150                provider,
151                model,
152                &original_history,
153                &engine_cfg,
154                context_budget,
155                parent_context.as_ref(),
156            )
157            .await?
158            {
159                let original_len = original_history.len();
160                let envelope = persist_memory_envelope_async_with_update(
161                    workspace_root,
162                    session_id,
163                    vt_cfg,
164                    &original_history,
165                    touched_files,
166                    &mut compacted,
167                    MemoryEnvelopePersistence::PersistToDisk,
168                    placement,
169                    None,
170                    steering_update,
171                )
172                .await?;
173                compacted =
174                    crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
175                let history_artifact_path = envelope.as_ref().and_then(|item| item.history_artifact_path.clone());
176                let compacted_len = compacted.len();
177                *history = compacted;
178                return Ok(Some(AutoCompactionOutcome {
179                    original_len,
180                    compacted_len,
181                    mode: CompactionMode::Local,
182                    history_artifact_path,
183                    envelope,
184                }));
185            }
186        }
187
188        // Route through the same strategy dispatch as the manual `/compact` command
189        // so every provider uses its correct native strategy (or falls back to a
190        // local LLM summary). Native outputs remain canonical replay windows, while
191        // local strategies provide the visible summary and session envelope.
192        let strategy = manual_compaction_strategy(provider, model);
193        let compaction_history = if matches!(strategy, CompactionStrategy::Local) {
194            dedup_repeated_file_reads_for_local_compaction(&compaction_input)
195        } else {
196            compaction_input.clone()
197        };
198
199        // Preserve the legacy small-segment short-circuit: skip tiny histories.
200        if !force_compaction
201            && !engine_cfg.always_summarize
202            && compaction_history.len() <= engine_cfg.keep_last_messages
203        {
204            return Ok(None);
205        }
206
207        let (mut compacted, mode) = compact_history_manual_with_parent_context(
208            provider,
209            model,
210            &compaction_history,
211            &engine_cfg,
212            &manual_options,
213            context_budget,
214            parent_context.as_ref(),
215        )
216        .await?;
217
218        if compacted == compaction_history {
219            return Ok(None);
220        }
221
222        // Never persist growth as a compaction: when the continuity tail
223        // already covers the history, the rebuild keeps every message and the
224        // envelope framing only adds more. Report already-compact instead.
225        if !compacted_history_shrinks(original_history.len(), compacted.len(), mode) {
226            return Ok(None);
227        }
228
229        let original_len = original_history.len();
230        // Memory-envelope persistence also injects a local system message. A
231        // native provider window is an opaque replay contract (for example an
232        // Anthropic signed compaction block must stay first), so persist the
233        // envelope against a private copy and leave the provider result intact.
234        let mut envelope_history = if mode == CompactionMode::Provider {
235            compacted.clone()
236        } else {
237            std::mem::take(&mut compacted)
238        };
239        let envelope = persist_memory_envelope_async_with_update(
240            workspace_root,
241            session_id,
242            vt_cfg,
243            &original_history,
244            touched_files,
245            &mut envelope_history,
246            MemoryEnvelopePersistence::PersistToDisk,
247            placement,
248            None,
249            steering_update,
250        )
251        .await?;
252        if mode == CompactionMode::Local {
253            compacted = envelope_history;
254            compacted =
255                crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
256        }
257        let history_artifact_path = envelope.as_ref().and_then(|item| item.history_artifact_path.clone());
258        let compacted_len = compacted.len();
259        *history = compacted;
260        Ok(Some(AutoCompactionOutcome {
261            original_len,
262            compacted_len,
263            mode,
264            history_artifact_path,
265            envelope,
266        }))
267    }
268    .await;
269
270    match &compaction_result {
271        Ok(Some(_)) => {
272            *auto_compact_suppressed = SUPPRESS_NONE;
273        }
274        Ok(None) => {}
275        Err(error) => {
276            let reason = classify_suppress_reason(&error.root_cause().to_string());
277            let new_state = reason.suppress_state();
278            // Only transition from SUPPRESS_NONE to a new state; don't downgrade
279            // a sticky/until-success suppression.
280            if *auto_compact_suppressed == SUPPRESS_NONE {
281                *auto_compact_suppressed = new_state;
282            }
283        }
284    }
285
286    compaction_result
287}
288
289/// Attempt two-pass compaction using a prefire-cached NOTE₁.
290///
291/// Returns `Some(compacted_history)` if the cache is valid and pass-2 produces
292/// a non-degenerate summary. `None` means the caller should fall back to
293/// single-pass compaction.
294///
295/// The pass-2 history already reuses the conversation prefix verbatim; the
296/// parent system/tools prefix is attached as well so the fork hits the
297/// provider prompt cache.
298async fn try_two_pass_with_prefire(
299    prefire: &crate::compaction::PrefireState,
300    provider: &dyn LLMProvider,
301    model: &str,
302    history: &[Message],
303    config: &CompactionConfig,
304    context_budget: Option<usize>,
305    parent: Option<&CompactionParentContext>,
306) -> Result<Option<Vec<Message>>> {
307    let cache = match prefire.take() {
308        Some(cache) => cache,
309        None => return Ok(None),
310    };
311
312    if cache.prefix_len == 0 || cache.prefix_len > history.len() {
313        return Ok(None);
314    }
315
316    if cache.model_slug != model {
317        return Ok(None);
318    }
319
320    let prefix = &history[..cache.prefix_len];
321    if fingerprint_prefix(prefix) != cache.fingerprint {
322        return Ok(None);
323    }
324
325    let tail = &history[cache.prefix_len..];
326    let prompt = build_summary_prompt(tail, &config.summary_prompt);
327    let pass2_history = crate::compaction::two_pass::build_two_pass_pass2_history(prefix, tail, &cache.note1, &prompt);
328
329    let request = LLMRequest {
330        messages: std::sync::Arc::new(pass2_history),
331        model: model.to_string(),
332        system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
333        tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
334        tool_choice: Some(ToolChoice::none()),
335        ..Default::default()
336    };
337
338    let response = collect_single_response(provider, request).await?;
339    let note2 = response.content.unwrap_or_default().trim().to_string();
340
341    if note2.trim().is_empty() {
342        return Ok(None);
343    }
344
345    let effective_config =
346        crate::compaction::context_bounded_compaction_config(provider, model, history, config, context_budget);
347    let tail_target = crate::compaction::route_tail_target_tokens(provider, model, context_budget);
348    let compacted = build_local_compacted_history(
349        history,
350        &note2,
351        effective_config.retained_user_message_tokens,
352        effective_config.retained_user_messages,
353        true,
354        tail_target,
355    );
356
357    let compacted = crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
358    // A degenerate pass-2 (tail already covers the history) keeps every
359    // message and only adds framing; persisting it would grow the
360    // conversation, so fall back to single-pass instead.
361    if !compacted_history_shrinks(history.len(), compacted.len(), CompactionMode::Local) {
362        return Ok(None);
363    }
364
365    Ok(Some(compacted))
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::compaction::SUPPRESS_STICKY;
372    use crate::compaction::two_pass::fingerprint_prefix;
373    use crate::llm::provider::{
374        LLMError, LLMNormalizedStream, LLMProvider, LLMRequest, LLMResponse, Message, NormalizedStreamEvent, ToolChoice,
375    };
376    use async_trait::async_trait;
377    use futures::stream;
378    use std::sync::Mutex;
379
380    struct FailingProvider;
381
382    struct SuccessfulProvider;
383
384    struct NativeStandaloneProvider {
385        output: Vec<Message>,
386    }
387
388    #[async_trait]
389    impl LLMProvider for SuccessfulProvider {
390        fn name(&self) -> &str {
391            "successful"
392        }
393
394        async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
395            Ok(LLMResponse::new("successful-model", "summary"))
396        }
397
398        fn supported_models(&self) -> Vec<String> {
399            vec!["successful-model".to_string()]
400        }
401
402        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
403            Ok(())
404        }
405
406        fn effective_context_size(&self, _model: &str) -> usize {
407            4_096
408        }
409    }
410
411    #[async_trait]
412    impl LLMProvider for NativeStandaloneProvider {
413        fn name(&self) -> &str {
414            "native-standalone"
415        }
416
417        async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
418            Ok(LLMResponse::new("native-model", "summary"))
419        }
420
421        fn supported_models(&self) -> Vec<String> {
422            vec!["native-model".to_string()]
423        }
424
425        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
426            Ok(())
427        }
428
429        fn supports_manual_openai_compaction(&self, _model: &str) -> bool {
430            true
431        }
432
433        fn effective_context_size(&self, _model: &str) -> usize {
434            4_096
435        }
436
437        async fn compact_history_with_options(
438            &self,
439            _model: &str,
440            _history: &[Message],
441            _options: &crate::llm::provider::ResponsesCompactionOptions,
442        ) -> Result<Vec<Message>, LLMError> {
443            Ok(self.output.clone())
444        }
445    }
446
447    #[async_trait]
448    impl LLMProvider for FailingProvider {
449        fn name(&self) -> &str {
450            "failing"
451        }
452
453        async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
454            Err(LLMError::Provider {
455                message: "context length exceeded".to_string(),
456                metadata: None,
457            })
458        }
459
460        fn supported_models(&self) -> Vec<String> {
461            vec!["failing-model".to_string()]
462        }
463
464        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
465            Ok(())
466        }
467
468        fn effective_context_size(&self, _model: &str) -> usize {
469            1000
470        }
471    }
472
473    struct CapturingProvider {
474        last_request: Mutex<Option<LLMRequest>>,
475    }
476
477    #[async_trait]
478    impl LLMProvider for CapturingProvider {
479        fn name(&self) -> &str {
480            "capturing"
481        }
482
483        fn supports_streaming(&self) -> bool {
484            true
485        }
486
487        fn supports_non_streaming(&self, _model: &str) -> bool {
488            false
489        }
490
491        async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
492            Err(LLMError::Provider {
493                message: "capturing provider is streaming-only".to_string(),
494                metadata: None,
495            })
496        }
497
498        async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
499            *self.last_request.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(request);
500            Ok(Box::pin(stream::iter(vec![Ok(NormalizedStreamEvent::Done {
501                response: Box::new(LLMResponse::new("capturing-model", "summary")),
502            })])))
503        }
504
505        fn supported_models(&self) -> Vec<String> {
506            vec!["capturing-model".to_string()]
507        }
508
509        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
510            Ok(())
511        }
512
513        fn effective_context_size(&self, _model: &str) -> usize {
514            4_096
515        }
516    }
517
518    #[tokio::test]
519    async fn suppressed_state_skips_compaction() {
520        let provider = FailingProvider;
521        let mut vt_cfg = VTCodeConfig::default();
522        vt_cfg.agent.harness.auto_compaction_enabled = true;
523        let mut history = vec![Message::user("hello".to_string())];
524        let mut suppressed = SUPPRESS_STICKY;
525
526        let result = auto_compact_messages(
527            AutoCompactionInput {
528                provider: &provider,
529                model: "failing-model",
530                session_id: "s1",
531                workspace_root: Path::new("."),
532                vt_cfg: Some(&vt_cfg),
533                reserved_output_tokens: 0,
534                current_token_usage: 900,
535                touched_files: &[],
536                engine_cfg: CompactionConfig::default(),
537                manual_options: ManualCompactionOptions::default(),
538                placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
539                prefire: None,
540                auto_compact_suppressed: &mut suppressed,
541                force_compaction: false,
542                steering_update: None,
543                parent_context: None,
544            },
545            &mut history,
546        )
547        .await;
548
549        assert!(result.unwrap().is_none());
550        assert_eq!(suppressed, SUPPRESS_STICKY);
551    }
552
553    #[tokio::test]
554    async fn failure_sets_suppression_on_deterministic_error() {
555        let provider = FailingProvider;
556        let mut vt_cfg = VTCodeConfig::default();
557        vt_cfg.agent.harness.auto_compaction_enabled = true;
558        let mut history = vec![Message::user("hello".to_string())];
559        let mut suppressed = SUPPRESS_NONE;
560
561        let result = auto_compact_messages(
562            AutoCompactionInput {
563                provider: &provider,
564                model: "failing-model",
565                session_id: "s1",
566                workspace_root: Path::new("."),
567                vt_cfg: Some(&vt_cfg),
568                reserved_output_tokens: 0,
569                // Crosses the effective threshold (provider capacity 4_096,
570                // zero reserve) so the deterministic provider failure is reached.
571                current_token_usage: 4_100,
572                touched_files: &[],
573                engine_cfg: CompactionConfig {
574                    keep_last_messages: 0,
575                    ..CompactionConfig::default()
576                },
577                manual_options: ManualCompactionOptions::default(),
578                placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
579                prefire: None,
580                auto_compact_suppressed: &mut suppressed,
581                force_compaction: false,
582                steering_update: None,
583                parent_context: None,
584            },
585            &mut history,
586        )
587        .await;
588
589        assert!(result.is_err());
590        assert_eq!(suppressed, SUPPRESS_STICKY);
591    }
592
593    #[tokio::test]
594    async fn automatic_compaction_persists_live_steering_snapshot() {
595        let provider = SuccessfulProvider;
596        let mut vt_cfg = VTCodeConfig::default();
597        vt_cfg.agent.harness.auto_compaction_enabled = true;
598        vt_cfg.context.dynamic.enabled = true;
599        vt_cfg.context.dynamic.persist_history = true;
600        let workspace = tempfile::tempdir().expect("tempdir");
601        // The history must exceed the ~1 KiB continuity tail (4 KiB window)
602        // so compaction genuinely shrinks instead of reporting already-compact.
603        let mut history = (0..60)
604            .map(|index| Message::user(format!("request {index} {}", "detail ".repeat(25))))
605            .collect::<Vec<_>>();
606        let intent = crate::core::agent::steering::QueuedFollowUpIntent::from_parts("intent-1", "finish the request");
607        let steering_update = SessionMemoryEnvelopeUpdate {
608            pending_intents: Some(vec![intent.clone()]),
609            applied_intent_ids: vec!["applied-1".to_string()],
610            ..Default::default()
611        };
612        let mut suppressed = SUPPRESS_NONE;
613
614        let result = auto_compact_messages(
615            AutoCompactionInput {
616                provider: &provider,
617                model: "successful-model",
618                session_id: "session-1",
619                workspace_root: workspace.path(),
620                vt_cfg: Some(&vt_cfg),
621                reserved_output_tokens: 0,
622                // Crosses the effective threshold (provider capacity 4_096,
623                // zero reserve) so automatic compaction runs.
624                current_token_usage: 4_100,
625                touched_files: &[],
626                engine_cfg: CompactionConfig {
627                    keep_last_messages: 0,
628                    ..CompactionConfig::default()
629                },
630                manual_options: ManualCompactionOptions::default(),
631                placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
632                prefire: None,
633                auto_compact_suppressed: &mut suppressed,
634                force_compaction: false,
635                steering_update: Some(&steering_update),
636                parent_context: None,
637            },
638            &mut history,
639        )
640        .await
641        .expect("automatic compaction should succeed")
642        .expect("automatic compaction should run");
643
644        let envelope = result.envelope.expect("compaction should produce an envelope");
645        assert_eq!(envelope.pending_intents, vec![intent]);
646        assert_eq!(envelope.applied_intent_ids, vec!["applied-1"]);
647    }
648
649    #[tokio::test]
650    async fn automatic_native_compaction_preserves_provider_window_when_persisting_envelope() {
651        let provider_output = vec![
652            Message::assistant(String::new()).with_reasoning_details(Some(vec![serde_json::json!({
653                "type": "compaction",
654                "content": null,
655                "signature": "signed-provider-state",
656            })])),
657            Message::user("provider-retained".to_string()),
658        ];
659        let provider = NativeStandaloneProvider { output: provider_output.clone() };
660        let mut vt_cfg = VTCodeConfig::default();
661        vt_cfg.agent.harness.auto_compaction_enabled = true;
662        vt_cfg.context.dynamic.enabled = true;
663        vt_cfg.context.dynamic.persist_history = true;
664        let workspace = tempfile::tempdir().expect("tempdir");
665        let mut history = (0..12)
666            .map(|index| Message::user(format!("request {index}")))
667            .collect::<Vec<_>>();
668        let mut suppressed = SUPPRESS_NONE;
669
670        let result = auto_compact_messages(
671            AutoCompactionInput {
672                provider: &provider,
673                model: "native-model",
674                session_id: "session-1",
675                workspace_root: workspace.path(),
676                vt_cfg: Some(&vt_cfg),
677                reserved_output_tokens: 0,
678                current_token_usage: 4_100,
679                touched_files: &[],
680                engine_cfg: CompactionConfig {
681                    keep_last_messages: 0,
682                    ..CompactionConfig::default()
683                },
684                manual_options: ManualCompactionOptions::default(),
685                placement: MemoryEnvelopePlacement::Start,
686                prefire: None,
687                auto_compact_suppressed: &mut suppressed,
688                force_compaction: false,
689                steering_update: None,
690                parent_context: None,
691            },
692            &mut history,
693        )
694        .await
695        .expect("automatic native compaction should succeed")
696        .expect("automatic native compaction should run");
697
698        assert_eq!(result.mode, CompactionMode::Provider);
699        assert!(result.envelope.is_some());
700        assert_eq!(history, provider_output, "provider output is the canonical replay window");
701    }
702
703    #[tokio::test]
704    async fn prefire_pass2_fork_reuses_parent_prefix() {
705        use crate::compaction::{AsyncCompactionCache, PrefireState};
706
707        let provider = CapturingProvider { last_request: Mutex::new(None) };
708        let mut vt_cfg = VTCodeConfig::default();
709        vt_cfg.agent.harness.auto_compaction_enabled = true;
710        let workspace = tempfile::tempdir().expect("tempdir");
711        // Sized past the ~1 KiB continuity tail so the two-pass rebuild
712        // shrinks the history instead of being discarded as already-compact.
713        let history = (0..60)
714            .map(|index| Message::user(format!("request {index} {}", "detail ".repeat(25))))
715            .collect::<Vec<_>>();
716        let mut history = history;
717        let prefix_len = 50;
718        let prefire = PrefireState::default();
719        prefire.store(AsyncCompactionCache {
720            note1: "prior summary".to_string(),
721            prefix_len,
722            fingerprint: fingerprint_prefix(&history[..prefix_len]),
723            model_slug: "capturing-model".to_string(),
724            pass1_latency_ms: 5,
725        });
726        let mut suppressed = SUPPRESS_NONE;
727
728        let result = auto_compact_messages(
729            AutoCompactionInput {
730                provider: &provider,
731                model: "capturing-model",
732                session_id: "session-1",
733                workspace_root: workspace.path(),
734                vt_cfg: Some(&vt_cfg),
735                reserved_output_tokens: 0,
736                // Crosses the effective threshold (provider capacity 4_096,
737                // zero reserve) so automatic compaction runs.
738                current_token_usage: 4_100,
739                touched_files: &[],
740                engine_cfg: CompactionConfig {
741                    keep_last_messages: 0,
742                    ..CompactionConfig::default()
743                },
744                manual_options: ManualCompactionOptions::default(),
745                placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
746                prefire: Some(&prefire),
747                auto_compact_suppressed: &mut suppressed,
748                force_compaction: false,
749                steering_update: None,
750                parent_context: Some(CompactionParentContext {
751                    system_prompt: Some(std::sync::Arc::from("parent system")),
752                    tools: None,
753                }),
754            },
755            &mut history,
756        )
757        .await
758        .expect("automatic compaction should succeed")
759        .expect("prefire two-pass compaction should run");
760
761        assert_eq!(result.mode, CompactionMode::Local);
762        let captured = provider.last_request.lock().unwrap().clone().expect("captured pass-2 request");
763        assert_eq!(captured.system_prompt.as_deref(), Some("parent system"));
764        assert!(matches!(captured.tool_choice, Some(ToolChoice::None)));
765        assert!(!captured.messages.is_empty());
766    }
767}