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