1use 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#[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 pub envelope: Option<crate::compaction::memory_envelope::SessionMemoryEnvelope>,
42}
43
44pub 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 pub current_token_usage: usize,
53 pub reserved_output_tokens: usize,
55 pub touched_files: &'a [String],
57 pub engine_cfg: CompactionConfig,
59 pub manual_options: ManualCompactionOptions,
61 pub placement: MemoryEnvelopePlacement,
63 pub prefire: Option<&'a crate::compaction::PrefireState>,
65 pub auto_compact_suppressed: &'a mut u8,
69 pub force_compaction: bool,
73 pub steering_update: Option<&'a SessionMemoryEnvelopeUpdate>,
75 pub parent_context: Option<CompactionParentContext>,
80}
81
82impl AutoCompactionInput<'_> {
83 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
105pub 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 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 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 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 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 if !compacted_history_shrinks(original_history.len(), compacted.len(), mode) {
229 return Ok(None);
230 }
231
232 let original_len = original_history.len();
233 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 if *auto_compact_suppressed == SUPPRESS_NONE {
284 *auto_compact_suppressed = new_state;
285 }
286 }
287 }
288
289 compaction_result
290}
291
292async 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 ¬e2,
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 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 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 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 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 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 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 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}