1use crate::core::config::{ContextManagementConfig, ContextManagementMode};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum WorkPhase {
20 #[default]
21 Unknown,
22 Execute,
24 WrapUp,
26 Plan,
28 NewTask,
29}
30
31impl WorkPhase {
32 pub fn parse(value: &str) -> Option<Self> {
33 match value.trim().to_ascii_lowercase().as_str() {
34 "unknown" => Some(Self::Unknown),
35 "execute" => Some(Self::Execute),
36 "wrap_up" | "wrapup" | "wrap-up" => Some(Self::WrapUp),
37 "plan" => Some(Self::Plan),
38 "new_task" | "newtask" | "new-task" => Some(Self::NewTask),
39 _ => None,
40 }
41 }
42
43 pub fn as_str(self) -> &'static str {
44 match self {
45 Self::Unknown => "unknown",
46 Self::Execute => "execute",
47 Self::WrapUp => "wrap_up",
48 Self::Plan => "plan",
49 Self::NewTask => "new_task",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct ContextState {
58 phase: WorkPhase,
59 plan_pending_execution: bool,
60 boundary_pending: bool,
61 rollover_due: bool,
62 rollover_required: bool,
63 finish_rounds_used: u32,
64 unproductive_rollover: Option<(u64, u8)>,
67}
68
69impl ContextState {
70 pub fn phase(&self) -> WorkPhase {
71 self.phase
72 }
73
74 pub fn finish_rounds_used(&self) -> u32 {
75 self.finish_rounds_used
76 }
77
78 pub fn report_phase(&mut self, phase: WorkPhase) {
81 match phase {
82 WorkPhase::Plan => self.plan_pending_execution = true,
83 WorkPhase::Execute if self.plan_pending_execution => {
84 self.boundary_pending = true;
85 self.plan_pending_execution = false;
86 }
87 WorkPhase::NewTask => self.boundary_pending = true,
88 _ => {}
89 }
90 self.phase = phase;
91 }
92
93 pub fn defer_unproductive_rollover(&mut self, used_tokens: u64) {
97 self.unproductive_rollover = Some((used_tokens, 0));
98 }
99
100 pub fn reset(&mut self) {
102 *self = Self::default();
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct ContextBudget {
109 pub context_window_tokens: u64,
110 pub used_tokens: u64,
112 pub hard_remaining_tokens: u64,
115 pub required_next_round_tokens: u64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ContextThresholds {
122 pub pressure_tokens: u64,
123 pub rollover_tokens: u64,
124}
125
126impl ContextThresholds {
127 pub fn resolve(
132 config: &ContextManagementConfig,
133 context_window_tokens: u64,
134 ) -> Result<Self, &'static str> {
135 config.validate_for_window(context_window_tokens)?;
136 let (pressure_tokens, rollover_tokens) = Self::bands(config, context_window_tokens);
137 Ok(Self {
138 pressure_tokens,
139 rollover_tokens,
140 })
141 }
142
143 pub(crate) fn bands(config: &ContextManagementConfig, window: u64) -> (u64, u64) {
146 let (pressure, rollover) = if window <= 200_000 {
147 ((window * 7 / 10).max(1), (window * 9 / 10).max(2))
148 } else if window < 1_000_000 {
149 let extra = window - 200_000;
150 (
151 140_000 + extra * 110_000 / 800_000,
152 180_000 + extra * 220_000 / 800_000,
153 )
154 } else {
155 (250_000, 400_000)
156 };
157 match (config.pressure_tokens, config.rollover_tokens) {
158 (Some(p), Some(r)) => (p, r),
159 (Some(p), None) => (p, rollover.max(p + 1)),
160 (None, Some(r)) => (pressure.min(r.saturating_sub(1)).max(1), r),
161 (None, None) => (pressure, rollover),
162 }
163 }
164
165 pub fn target_tokens(&self, config: &ContextManagementConfig) -> u64 {
170 config
171 .target_tokens
172 .unwrap_or_else(|| DEFAULT_TARGET_TOKENS.min(self.pressure_tokens * 2 / 3))
173 .clamp(1, self.pressure_tokens.saturating_sub(1).max(1))
174 }
175}
176
177pub const DEFAULT_TARGET_TOKENS: u64 = 100_000;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum ContextBand {
182 Normal,
183 Pressure,
184 Rollover,
185 HardLimit,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum ContextAction {
190 Disabled,
191 Continue,
192 Advisory,
194 FinishBounded {
196 rounds_remaining: u32,
197 },
198 Rollover,
200 HardStop,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum ContextReason {
207 ModeOff,
208 BelowPressure,
209 PrepareForRollover,
210 TaskBoundary,
211 RolloverThreshold,
212 BoundedFinish,
213 FinishAllowanceExhausted,
214 RolloverPending,
215 UnproductiveRollover,
216 HardCapacity,
217 InvalidConfig,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct ContextAssessment {
222 pub action: ContextAction,
223 pub band: ContextBand,
224 pub reason: ContextReason,
225 pub thresholds: Option<ContextThresholds>,
227 pub next_state: ContextState,
230}
231
232pub fn assess_context(
236 config: &ContextManagementConfig,
237 state: &ContextState,
238 budget: ContextBudget,
239) -> ContextAssessment {
240 let mut assessment = ContextAssessment {
241 action: ContextAction::HardStop,
242 band: ContextBand::HardLimit,
243 reason: ContextReason::HardCapacity,
244 thresholds: None,
245 next_state: *state,
246 };
247 let remaining = budget.hard_remaining_tokens.min(
248 budget
249 .context_window_tokens
250 .saturating_sub(budget.used_tokens),
251 );
252 let required = budget.required_next_round_tokens.max(config.reserve_tokens);
253 if remaining == 0 || remaining < required {
254 return assessment;
255 }
256
257 assessment.next_state.boundary_pending = false;
260 if config.mode == ContextManagementMode::Off {
261 assessment.action = ContextAction::Disabled;
262 assessment.band = ContextBand::Normal;
263 assessment.reason = ContextReason::ModeOff;
264 return assessment;
265 }
266 let Ok(thresholds) = ContextThresholds::resolve(config, budget.context_window_tokens) else {
267 assessment.reason = ContextReason::InvalidConfig;
268 return assessment;
269 };
270 assessment.thresholds = Some(thresholds);
271 assessment.band = if budget.used_tokens >= thresholds.rollover_tokens {
272 ContextBand::Rollover
273 } else if budget.used_tokens >= thresholds.pressure_tokens {
274 ContextBand::Pressure
275 } else {
276 ContextBand::Normal
277 };
278
279 if let Some((used, skipped)) = state.unproductive_rollover {
284 if budget.used_tokens.abs_diff(used) < 8_192 && skipped < 4 {
285 assessment.next_state.unproductive_rollover = Some((used, skipped + 1));
286 assessment.action = ContextAction::Advisory;
287 assessment.reason = ContextReason::UnproductiveRollover;
288 return assessment;
289 }
290 assessment.next_state.unproductive_rollover = None;
291 }
292
293 if state.rollover_required {
294 assessment.action = ContextAction::Rollover;
295 assessment.reason = ContextReason::RolloverPending;
296 return assessment;
297 }
298 if budget.used_tokens >= thresholds.pressure_tokens
299 && (state.boundary_pending || state.phase == WorkPhase::NewTask)
300 {
301 assessment.next_state.rollover_required = true;
302 assessment.action = ContextAction::Rollover;
303 assessment.reason = ContextReason::TaskBoundary;
304 return assessment;
305 }
306 if assessment.band == ContextBand::Rollover || state.rollover_due {
307 assessment.next_state.rollover_due = true;
310 if matches!(state.phase, WorkPhase::Plan | WorkPhase::WrapUp)
311 && state.finish_rounds_used < config.finish_rounds
312 {
313 assessment.next_state.finish_rounds_used += 1;
314 assessment.action = ContextAction::FinishBounded {
315 rounds_remaining: config.finish_rounds - assessment.next_state.finish_rounds_used,
316 };
317 assessment.reason = ContextReason::BoundedFinish;
318 } else {
319 assessment.next_state.rollover_required = true;
320 assessment.action = ContextAction::Rollover;
321 assessment.reason = if state.finish_rounds_used >= config.finish_rounds {
322 ContextReason::FinishAllowanceExhausted
323 } else {
324 ContextReason::RolloverThreshold
325 };
326 }
327 } else if assessment.band == ContextBand::Pressure {
328 assessment.action = ContextAction::Advisory;
329 assessment.reason = ContextReason::PrepareForRollover;
330 } else {
331 assessment.action = ContextAction::Continue;
332 assessment.reason = ContextReason::BelowPressure;
333 }
334 assessment
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn auto_config() -> ContextManagementConfig {
342 ContextManagementConfig {
343 mode: ContextManagementMode::Auto,
344 ..Default::default()
345 }
346 }
347
348 fn budget(window: u64, used: u64) -> ContextBudget {
349 ContextBudget {
350 context_window_tokens: window,
351 used_tokens: used,
352 hard_remaining_tokens: window.saturating_sub(used),
353 required_next_round_tokens: 8_000,
354 }
355 }
356
357 #[test]
358 fn unproductive_rollover_defers_only_soft_admission_and_retries_after_progress() {
359 let config = auto_config();
360 let mut state = ContextState::default();
361 state.report_phase(WorkPhase::NewTask);
362 state = assess_context(&config, &state, budget(1_000_000, 339_058)).next_state;
363 state.defer_unproductive_rollover(339_058);
364 for _ in 0..4 {
365 state.report_phase(WorkPhase::NewTask); let next = assess_context(&config, &state, budget(1_000_000, 339_100));
367 assert_eq!(next.action, ContextAction::Advisory);
368 assert_eq!(next.reason, ContextReason::UnproductiveRollover);
369 state = next.next_state;
370 }
371 assert_eq!(
372 assess_context(&config, &state, budget(1_000_000, 339_100)).action,
373 ContextAction::Rollover
374 );
375 for changed in [329_000, 350_000] {
376 state.defer_unproductive_rollover(339_058);
377 assert_eq!(
378 assess_context(&config, &state, budget(1_000_000, changed)).action,
379 ContextAction::Rollover
380 );
381 }
382 state.reset();
383 assert_eq!(state.unproductive_rollover, None);
384 assert_eq!(state.phase(), WorkPhase::Unknown);
385 }
386
387 #[test]
388 fn unproductive_cooldown_cannot_bypass_hard_budget_or_renew_finish_allowance() {
389 let config = auto_config();
390 let mut state = ContextState::default();
391 state.report_phase(WorkPhase::Plan);
392 for _ in 0..config.finish_rounds {
393 state = assess_context(&config, &state, budget(1_000_000, 400_000)).next_state;
394 }
395 state = assess_context(&config, &state, budget(1_000_000, 400_000)).next_state;
396 state.defer_unproductive_rollover(400_000);
397 state.report_phase(WorkPhase::WrapUp);
398 let off = ContextManagementConfig {
399 mode: ContextManagementMode::Off,
400 ..config
401 };
402 state = assess_context(&off, &state, budget(1_000_000, 400_000)).next_state;
403 assert_eq!(state.unproductive_rollover, Some((400_000, 0)));
404 assert_eq!(state.finish_rounds_used(), config.finish_rounds);
405 let denied = assess_context(
406 &config,
407 &state,
408 ContextBudget {
409 hard_remaining_tokens: 0,
410 ..budget(1_000_000, 400_000)
411 },
412 );
413 assert_eq!(denied.action, ContextAction::HardStop);
414 assert_eq!(denied.next_state, state);
415 assert_eq!(
416 assess_context(&config, &state, budget(410_000, 400_000)).action,
417 ContextAction::HardStop
418 );
419 let allowed = assess_context(&config, &state, budget(1_000_000, 400_000));
420 assert_eq!(allowed.reason, ContextReason::UnproductiveRollover);
421 assert_eq!(
422 allowed.next_state.finish_rounds_used(),
423 config.finish_rounds
424 );
425 }
426
427 #[test]
428 fn successor_target_defaults_to_100k_and_scales_below_small_pressure() {
429 let config = auto_config();
430 for (window, target) in [
431 (1_000_000, 100_000),
432 (2_000_000, 100_000),
433 (200_000, 93_333),
434 (32_000, 14_933),
435 ] {
436 let t = ContextThresholds::resolve(&config, window).unwrap();
437 assert_eq!(t.target_tokens(&config), target, "window {window}");
438 assert!(t.target_tokens(&config) < t.pressure_tokens);
439 }
440 let explicit = ContextManagementConfig {
441 target_tokens: Some(120_000),
442 ..config
443 };
444 let t = ContextThresholds::resolve(&explicit, 1_000_000).unwrap();
445 assert_eq!(t.target_tokens(&explicit), 120_000);
446 for bad in [Some(0), Some(250_000), Some(300_000)] {
447 let invalid = ContextManagementConfig {
448 target_tokens: bad,
449 ..config
450 };
451 assert!(
452 ContextThresholds::resolve(&invalid, 1_000_000).is_err(),
453 "{bad:?}"
454 );
455 }
456 let small = ContextManagementConfig {
458 target_tokens: Some(100_000),
459 ..config
460 };
461 assert!(ContextThresholds::resolve(&small, 128_000).is_err());
462 }
463
464 #[test]
465 fn context_threshold_anchors_and_pressure_are_advisory() {
466 let config = auto_config();
467 for (window, pressure, rollover) in [
468 (200_000, 140_000, 180_000),
469 (1_000_000, 250_000, 400_000),
470 (2_000_000, 250_000, 400_000),
471 ] {
472 assert_eq!(
473 ContextThresholds::resolve(&config, window).unwrap(),
474 ContextThresholds {
475 pressure_tokens: pressure,
476 rollover_tokens: rollover
477 }
478 );
479 let mut state = ContextState::default();
480 state.report_phase(WorkPhase::Execute);
481 assert_eq!(
482 assess_context(&config, &state, budget(window, pressure - 1)).action,
483 ContextAction::Continue
484 );
485 let at_pressure = assess_context(&config, &state, budget(window, pressure));
486 assert_eq!(at_pressure.action, ContextAction::Advisory);
487 assert_eq!(at_pressure.band, ContextBand::Pressure);
488 assert_eq!(
489 assess_context(&config, &state, budget(window, rollover - 1)).action,
490 ContextAction::Advisory
491 );
492 assert_eq!(
493 assess_context(&config, &state, budget(window, rollover)).action,
494 ContextAction::Rollover
495 );
496 }
497 }
498
499 #[test]
500 fn context_350k_plan_finishes_before_execute_requests_rollover() {
501 let config = auto_config();
502 let current_budget = budget(1_000_000, 350_000);
503 for phase in [WorkPhase::Execute, WorkPhase::WrapUp, WorkPhase::Plan] {
504 let mut state = ContextState::default();
505 state.report_phase(phase);
506 assert_eq!(
507 assess_context(&config, &state, current_budget).action,
508 ContextAction::Advisory
509 );
510 }
511 let mut state = ContextState::default();
512 state.report_phase(WorkPhase::Plan);
513 state = assess_context(&config, &state, current_budget).next_state;
514 state.report_phase(WorkPhase::Unknown);
515 state.report_phase(WorkPhase::WrapUp);
516 state.report_phase(WorkPhase::Execute);
517 state.report_phase(WorkPhase::Plan); let assessment = assess_context(&config, &state, current_budget);
519 assert_eq!(assessment.action, ContextAction::Rollover);
520 assert_eq!(assessment.reason, ContextReason::TaskBoundary);
521 state = assessment.next_state;
522 state.report_phase(WorkPhase::WrapUp);
523 assert_eq!(
524 assess_context(&config, &state, current_budget).action,
525 ContextAction::Rollover
526 );
527 state.reset();
528 assert_eq!(
529 assess_context(&config, &state, budget(1_000_000, 0)).action,
530 ContextAction::Continue
531 );
532 }
533
534 #[test]
535 fn context_new_task_rolls_over_at_pressure_not_before() {
536 let config = auto_config();
537 let mut state = ContextState::default();
538 state.report_phase(WorkPhase::NewTask);
539 assert_eq!(
540 assess_context(&config, &state, budget(1_000_000, 249_999)).action,
541 ContextAction::Continue
542 );
543 assert_eq!(
544 assess_context(&config, &state, budget(1_000_000, 250_000)).action,
545 ContextAction::Rollover
546 );
547 }
548
549 #[test]
550 fn context_low_pressure_boundary_is_consumed_at_admission() {
551 let config = auto_config();
552 let mut state = ContextState::default();
553 state.report_phase(WorkPhase::Plan);
554 state.report_phase(WorkPhase::Execute);
555 state = assess_context(&config, &state, budget(1_000_000, 100_000)).next_state;
556 assert_eq!(
557 assess_context(&config, &state, budget(1_000_000, 350_000)).action,
558 ContextAction::Advisory
559 );
560 }
561
562 #[test]
563 fn context_400k_finish_is_bounded_across_reports_and_estimate_decreases() {
564 let config = auto_config();
565 for phase in [WorkPhase::Plan, WorkPhase::WrapUp] {
566 let mut state = ContextState::default();
567 state.report_phase(phase);
568 let first = assess_context(&config, &state, budget(1_000_000, 400_000));
569 assert_eq!(
570 first.action,
571 ContextAction::FinishBounded {
572 rounds_remaining: 1
573 }
574 );
575 assert_eq!(state.finish_rounds_used(), 0); assert_eq!(
577 first,
578 assess_context(&config, &state, budget(1_000_000, 400_000))
579 );
580 state = first.next_state;
581 state.report_phase(WorkPhase::Unknown);
582 state.report_phase(phase);
583 let second = assess_context(&config, &state, budget(1_000_000, 350_000));
584 assert_eq!(
585 second.action,
586 ContextAction::FinishBounded {
587 rounds_remaining: 0
588 }
589 );
590 state = second.next_state;
591 assert_eq!(state.finish_rounds_used(), 2);
592 state.report_phase(phase);
593 let exhausted = assess_context(&config, &state, budget(1_000_000, 100_000));
594 assert_eq!(exhausted.action, ContextAction::Rollover);
595 assert_eq!(exhausted.reason, ContextReason::FinishAllowanceExhausted);
596 state = exhausted.next_state;
597 state.report_phase(WorkPhase::Plan);
598 assert_eq!(
599 assess_context(&config, &state, budget(1_000_000, 100_000)).action,
600 ContextAction::Rollover
601 );
602 state.reset();
603 assert_eq!(state.finish_rounds_used(), 0);
604 assert_eq!(state.phase(), WorkPhase::Unknown);
605 }
606 }
607
608 #[test]
609 fn context_finish_override_is_bounded_including_zero() {
610 for limit in [0, 1, ContextManagementConfig::MAX_FINISH_ROUNDS] {
611 let config = ContextManagementConfig {
612 finish_rounds: limit,
613 ..auto_config()
614 };
615 let mut state = ContextState::default();
616 state.report_phase(WorkPhase::WrapUp);
617 for used in 0..limit {
618 let assessment = assess_context(&config, &state, budget(1_000_000, 400_000));
619 assert_eq!(
620 assessment.action,
621 ContextAction::FinishBounded {
622 rounds_remaining: limit - used - 1
623 }
624 );
625 state = assessment.next_state;
626 }
627 assert_eq!(
628 assess_context(&config, &state, budget(1_000_000, 400_000)).action,
629 ContextAction::Rollover
630 );
631 }
632 }
633
634 #[test]
635 fn context_off_is_default_and_mode_toggles_do_not_replenish_finish() {
636 let off = ContextManagementConfig::default();
637 let mut state = ContextState::default();
638 state.report_phase(WorkPhase::WrapUp);
639 assert_eq!(
640 assess_context(&off, &state, budget(1_000_000, 400_000)).action,
641 ContextAction::Disabled
642 );
643 state = assess_context(&auto_config(), &state, budget(1_000_000, 400_000)).next_state;
644 state = assess_context(&off, &state, budget(1_000_000, 410_000)).next_state;
645 assert_eq!(state.finish_rounds_used(), 1);
646 let second = assess_context(&auto_config(), &state, budget(1_000_000, 420_000));
647 assert_eq!(
648 second.action,
649 ContextAction::FinishBounded {
650 rounds_remaining: 0
651 }
652 );
653 assert_eq!(
654 assess_context(
655 &auto_config(),
656 &second.next_state,
657 budget(1_000_000, 420_000)
658 )
659 .action,
660 ContextAction::Rollover
661 );
662 }
663
664 #[test]
665 fn context_hard_capacity_wins_for_every_phase_and_mode() {
666 for mode in [ContextManagementMode::Off, ContextManagementMode::Auto] {
667 let config = ContextManagementConfig {
668 mode,
669 ..Default::default()
670 };
671 for phase in [
672 WorkPhase::Unknown,
673 WorkPhase::Execute,
674 WorkPhase::WrapUp,
675 WorkPhase::Plan,
676 WorkPhase::NewTask,
677 ] {
678 let mut state = ContextState::default();
679 state.report_phase(phase);
680 for hard_budget in [
681 ContextBudget {
682 hard_remaining_tokens: 15_999,
683 ..budget(1_000_000, 350_000)
684 },
685 ContextBudget {
686 required_next_round_tokens: 650_001,
687 ..budget(1_000_000, 350_000)
688 },
689 ContextBudget {
690 hard_remaining_tokens: u64::MAX,
691 ..budget(200_000, 190_000)
692 },
693 budget(200_000, 200_001),
694 budget(0, 0),
695 ] {
696 let assessment = assess_context(&config, &state, hard_budget);
697 assert_eq!(
698 assessment.action,
699 ContextAction::HardStop,
700 "{mode:?} {phase:?} {hard_budget:?}"
701 );
702 assert_eq!(assessment.band, ContextBand::HardLimit);
703 assert_eq!(assessment.reason, ContextReason::HardCapacity);
704 assert_eq!(assessment.next_state, state);
705 }
706 }
707 }
708 }
709
710 #[test]
711 fn context_exact_reserve_is_allowed_and_next_round_can_require_more() {
712 let config = auto_config();
713 let state = ContextState::default();
714 let exact = ContextBudget {
715 hard_remaining_tokens: 16_000,
716 ..budget(200_000, 140_000)
717 };
718 assert_eq!(
719 assess_context(&config, &state, exact).action,
720 ContextAction::Advisory
721 );
722 let larger = ContextBudget {
723 required_next_round_tokens: 16_001,
724 ..exact
725 };
726 assert_eq!(
727 assess_context(&config, &state, larger).action,
728 ContextAction::HardStop
729 );
730 }
731
732 #[test]
733 fn context_threshold_overrides_preserve_explicit_values() {
734 for (pressure, rollover, expected) in [
735 (Some(350_000), Some(500_000), (350_000, 500_000)),
736 (Some(450_000), None, (450_000, 450_001)),
737 (None, Some(200_000), (199_999, 200_000)),
738 ] {
739 let config = ContextManagementConfig {
740 pressure_tokens: pressure,
741 rollover_tokens: rollover,
742 ..auto_config()
743 };
744 let thresholds = ContextThresholds::resolve(&config, 1_000_000).unwrap();
745 assert_eq!(
746 (thresholds.pressure_tokens, thresholds.rollover_tokens),
747 expected
748 );
749 }
750 assert!(ContextThresholds::resolve(&auto_config(), u64::MAX).is_ok());
751 assert_eq!(
752 ContextThresholds::resolve(&auto_config(), 100_000)
753 .unwrap()
754 .pressure_tokens,
755 70_000
756 );
757 assert_eq!(
758 ContextThresholds::resolve(&auto_config(), 600_000)
759 .unwrap()
760 .rollover_tokens,
761 290_000
762 );
763 }
764
765 #[test]
766 fn context_invalid_programmatic_config_fails_closed() {
767 for config in [
768 ContextManagementConfig {
769 pressure_tokens: Some(0),
770 ..auto_config()
771 },
772 ContextManagementConfig {
773 pressure_tokens: Some(u64::MAX),
774 ..auto_config()
775 },
776 ContextManagementConfig {
777 rollover_tokens: Some(1_000_001),
778 ..auto_config()
779 },
780 ContextManagementConfig {
781 finish_rounds: 9,
782 ..auto_config()
783 },
784 ContextManagementConfig {
785 reserve_tokens: 0,
786 ..auto_config()
787 },
788 ] {
789 let assessment = assess_context(
790 &config,
791 &ContextState::default(),
792 budget(1_000_000, 350_000),
793 );
794 assert_eq!(assessment.action, ContextAction::HardStop);
795 assert_eq!(assessment.reason, ContextReason::InvalidConfig);
796 }
797 }
798
799 #[test]
800 fn context_work_phase_roundtrips_and_rejects_unknown_reports() {
801 for phase in [
802 WorkPhase::Unknown,
803 WorkPhase::Execute,
804 WorkPhase::WrapUp,
805 WorkPhase::Plan,
806 WorkPhase::NewTask,
807 ] {
808 assert_eq!(WorkPhase::parse(phase.as_str()), Some(phase));
809 }
810 assert_eq!(WorkPhase::parse(" PLAN "), Some(WorkPhase::Plan));
811 assert_eq!(WorkPhase::parse("wrap-up"), Some(WorkPhase::WrapUp));
812 assert_eq!(WorkPhase::parse("new-task"), Some(WorkPhase::NewTask));
813 assert_eq!(WorkPhase::parse("finish_forever"), None);
814 }
815}