返回 CodeWhale
compaction_flow.rs
根目录 / crates / tui / src / tui / ui / compaction_flow.rs
1 //! Compaction UI state: manual/automatic compaction queueing, settlement,
2 //! receipts, and cancel behavior (TUI_MODULARIZATION.md slice 7). The engine
3 //! owns the actual summarization; this module projects its lifecycle into the
4 //! UI and never awaits the bounded engine mailbox from the event loop.
5
6 use super::*;
7
8 /// Queue a live compaction update without waiting on the engine mailbox.
9 ///
10 /// Config edits are valid while a turn is streaming, but awaiting a bounded
11 /// engine mailbox from the UI event loop can make the whole TUI appear frozen
12 /// when the turn is busy. A dropped refresh is safe: the next turn rebuilds
13 /// its compaction config from `App`, and the status message tells the user
14 /// whether the update was queued or deferred.
15 ///
16 /// The model and its compaction budget are one route update: both mailbox
17 /// slots are reserved before either op is sent, so a full mailbox defers the
18 /// whole update instead of applying the model without its budget (U03-05).
19 pub(crate) fn try_apply_model_and_compaction_update(
20 engine_handle: &EngineHandle,
21 compaction: crate::compaction::CompactionConfig,
22 mode: AppMode,
23 route_limits: Option<codewhale_config::route::RouteLimits>,
24 ) -> bool {
25 let Ok(model_permit) = engine_handle.tx_op.clone().try_reserve_owned() else {
26 return false;
27 };
28 let Ok(compaction_permit) = engine_handle.tx_op.clone().try_reserve_owned() else {
29 return false;
30 };
31 engine_handle.send_reserved_op(
32 model_permit,
33 Op::SetModel {
34 model: compaction.model.clone(),
35 mode,
36 route_limits,
37 },
38 );
39 engine_handle.send_reserved_op(compaction_permit, Op::SetCompaction { config: compaction });
40 true
41 }
42
43 pub(crate) fn set_explicit_compaction_status(
44 app: &mut App,
45 text: String,
46 level: StatusToastLevel,
47 sticky: bool,
48 ) {
49 app.status_message = Some(text.clone());
50 // This lifecycle reducer assigns the semantic level explicitly. Mark the
51 // legacy status bridge as synchronized so it cannot add a second,
52 // keyword-classified toast with a different level on the next frame.
53 app.last_status_message_seen = Some(text.clone());
54 if sticky {
55 app.set_sticky_status(text, level, Some(App::STICKY_ERROR_TTL_MS));
56 } else {
57 app.push_status_toast(text, level, Some(5_000));
58 }
59 }
60
61 /// Queue manual compaction without ever awaiting the bounded engine mailbox
62 /// from the terminal event loop.
63 ///
64 /// During an active turn, a successful send is intentionally deferred until
65 /// the engine returns to its outer operation loop. Full and closed mailboxes
66 /// are rejected immediately with an actionable receipt, so `/compact` cannot
67 /// freeze keyboard input or rendering.
68 pub(crate) fn try_queue_manual_compaction(
69 app: &mut App,
70 config: &Config,
71 engine_handle: &EngineHandle,
72 focus: Option<String>,
73 ) {
74 if app.is_compacting || app.manual_compaction_queued {
75 let text = app
76 .tr(MessageId::ContextCompactionAlreadyRunning)
77 .into_owned();
78 add_compaction_receipt(app, &text);
79 set_explicit_compaction_status(app, text, StatusToastLevel::Warning, false);
80 return;
81 }
82
83 let route = match validated_app_runtime_route(app, config) {
84 Ok(route) => route,
85 Err(error) => {
86 let text = app
87 .tr(MessageId::ContextCompactionRouteInvalid)
88 .replace("{error}", &error.to_string());
89 add_compaction_receipt(app, &text);
90 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
91 return;
92 }
93 };
94 let mut compaction = compaction_for_validated_route(app, &route);
95 compaction.focus = focus.clone();
96 let request_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
97 let op = Op::CompactContext {
98 id: request_id.clone(),
99 route: Box::new(route.into_resolved()),
100 compaction: Box::new(compaction),
101 };
102
103 match engine_handle.try_send(op) {
104 Ok(()) => {
105 app.manual_compaction_queued = true;
106 app.manual_compaction_id = Some(request_id);
107 let id = if app.is_loading {
108 MessageId::ContextCompactionQueued
109 } else {
110 MessageId::ContextManualCompacting
111 };
112 let text = app.tr(id).into_owned();
113 // Queued-behind-a-turn is a state the user must be able to find
114 // again after the 5s toast: leave it in the transcript too.
115 if app.is_loading {
116 add_compaction_receipt(app, &text);
117 }
118 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
119 }
120 Err(error) => {
121 let full = error
122 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
123 .is_some_and(|send_error| {
124 matches!(send_error, tokio::sync::mpsc::error::TrySendError::Full(_))
125 });
126 if full {
127 // A saturated mailbox is a timing accident of the active turn,
128 // not a user error. Queue client-side and let the event loop
129 // retry once the engine drains a slot; the user sees the same
130 // queued receipt as the ordinary behind-a-turn path.
131 app.manual_compaction_queued = true;
132 app.manual_compaction_id = Some(request_id);
133 app.deferred_manual_compaction = Some(focus);
134 let text = app.tr(MessageId::ContextCompactionQueued).into_owned();
135 add_compaction_receipt(app, &text);
136 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
137 } else {
138 let text = app.tr(MessageId::ContextCompactionQueueClosed).into_owned();
139 add_compaction_receipt(app, &text);
140 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
141 }
142 }
143 }
144 }
145
146 /// Retry a manual compaction that was deferred by a full engine mailbox.
147 ///
148 /// Called once per event-loop iteration. Silent by design: the queued receipt
149 /// was already written when the request was deferred, a still-full mailbox
150 /// just waits for the next iteration, and a compaction that started or
151 /// settled in the meantime supersedes the request entirely (handled by
152 /// `apply_compaction_started`/`settle_compaction`).
153 pub(crate) fn flush_deferred_manual_compaction(
154 app: &mut App,
155 config: &Config,
156 engine_handle: &EngineHandle,
157 ) {
158 if app.deferred_manual_compaction.is_none() || app.is_compacting {
159 return;
160 }
161 let route = match validated_app_runtime_route(app, config) {
162 Ok(route) => route,
163 Err(error) => {
164 app.deferred_manual_compaction = None;
165 app.manual_compaction_queued = false;
166 app.manual_compaction_id = None;
167 let text = app
168 .tr(MessageId::ContextCompactionRouteInvalid)
169 .replace("{error}", &error.to_string());
170 add_compaction_receipt(app, &text);
171 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
172 return;
173 }
174 };
175 let focus = app.deferred_manual_compaction.clone().unwrap_or_default();
176 let Some(request_id) = app.manual_compaction_id.clone() else {
177 app.deferred_manual_compaction = None;
178 app.manual_compaction_queued = false;
179 return;
180 };
181 let mut compaction = compaction_for_validated_route(app, &route);
182 compaction.focus = focus;
183 let op = Op::CompactContext {
184 id: request_id,
185 route: Box::new(route.into_resolved()),
186 compaction: Box::new(compaction),
187 };
188 match engine_handle.try_send(op) {
189 Ok(()) => {
190 app.deferred_manual_compaction = None;
191 }
192 Err(error) => {
193 let full = error
194 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
195 .is_some_and(|send_error| {
196 matches!(send_error, tokio::sync::mpsc::error::TrySendError::Full(_))
197 });
198 if !full {
199 app.deferred_manual_compaction = None;
200 app.manual_compaction_queued = false;
201 app.manual_compaction_id = None;
202 let text = app.tr(MessageId::ContextCompactionQueueClosed).into_owned();
203 add_compaction_receipt(app, &text);
204 set_explicit_compaction_status(app, text, StatusToastLevel::Error, true);
205 }
206 }
207 }
208 }
209
210 pub(crate) fn apply_compaction_started(app: &mut App, id: String, auto: bool) {
211 if app.sticky_status.as_ref().is_some_and(|status| {
212 matches!(
213 status.kind,
214 crate::tui::app::StatusToastKind::ContextPressure(_)
215 )
216 }) {
217 app.clear_sticky_status();
218 }
219 app.context_pressure_warning_dismissed = None;
220 if !auto {
221 app.manual_compaction_queued = false;
222 if app.manual_compaction_id.as_deref() == Some(id.as_str()) {
223 app.manual_compaction_id = None;
224 }
225 }
226 // A compaction is running; a deferred manual request is now redundant.
227 // Dropping it must also release the queued flag when the running pass is
228 // automatic, or `/compact` would report "already in progress" forever.
229 if app.deferred_manual_compaction.take().is_some() && auto {
230 app.manual_compaction_queued = false;
231 app.manual_compaction_id = None;
232 }
233 app.active_compaction = Some(ActiveCompaction { id, auto });
234 app.is_compacting = true;
235 if !auto {
236 let text = app.tr(MessageId::ContextManualCompacting).into_owned();
237 set_explicit_compaction_status(app, text, StatusToastLevel::Info, false);
238 }
239 }
240
241 /// Clear the compaction-in-flight state for a terminal lifecycle event.
242 ///
243 /// An exact id match clears normally. A terminal event with NO tracked
244 /// compaction is still authoritative (the started event can be lost to a
245 /// dropped drain or session switch): without this, `is_compacting`/
246 /// `manual_compaction_queued` stayed latched and every later `/compact` was
247 /// silently rejected as "already in progress". A stale event while a NEWER
248 /// compaction is live must not clear it (or report anything) — that live
249 /// pass gets its own terminal event. Returns whether the event settled.
250 pub(crate) fn settle_compaction(app: &mut App, id: &str, auto: bool) -> bool {
251 if app
252 .active_compaction
253 .as_ref()
254 .is_some_and(|active| active.id != id || active.auto != auto)
255 {
256 return false;
257 }
258 app.active_compaction = None;
259 app.is_compacting = false;
260 if !auto {
261 app.manual_compaction_queued = false;
262 app.manual_compaction_id = None;
263 }
264 // A settled pass makes a still-deferred manual request redundant (the
265 // context was just compacted). Dropping it releases the queued flag so a
266 // later `/compact` is not rejected as "already in progress".
267 if app.deferred_manual_compaction.take().is_some() {
268 app.manual_compaction_queued = false;
269 app.manual_compaction_id = None;
270 }
271 true
272 }
273
274 /// Durable transcript receipt for a compaction outcome.
275 ///
276 /// Outcome feedback used to be toast-only, and the engine emits
277 /// `TurnComplete` immediately after the compaction event — both land in the
278 /// same UI drain batch, so the turn's "done" status replaced the completion
279 /// toast before a single frame was drawn. `/compact` looked like a no-op
280 /// even when the summary committed (the v0.9.6 release blocker).
281 pub(crate) fn add_compaction_receipt(app: &mut App, message: &str) {
282 app.add_message(HistoryCell::System {
283 content: message.to_string(),
284 });
285 }
286
287 pub(crate) fn apply_compaction_completed(
288 app: &mut App,
289 id: &str,
290 auto: bool,
291 message: String,
292 messages_before: Option<usize>,
293 messages_after: Option<usize>,
294 summary_prompt: Option<String>,
295 ) {
296 if settle_compaction(app, id, auto) {
297 // The billed prompt receipt described the pre-compaction context;
298 // after the rewrite the local estimate is the honest signal until
299 // the next model call bills the new, smaller prompt (#5577).
300 app.last_billed_input_tokens = None;
301 let keep = crate::compaction::inspect_compaction_keep(&app.api_messages);
302 let path = if summary_prompt
303 .as_deref()
304 .is_some_and(|text| !text.trim().is_empty())
305 {
306 crate::compaction::CompactionPath::Summary
307 } else {
308 crate::compaction::CompactionPath::PruneOnly
309 };
310 let after = messages_after.unwrap_or(app.api_messages.len());
311 let before = messages_before.unwrap_or(after);
312 app.last_compaction = Some(crate::compaction::LastCompactionSnapshot {
313 auto,
314 coverage: crate::compaction::CompactionCoverage {
315 path,
316 last_round_messages: keep.last_round_messages,
317 last_round_tool_results: keep.last_round_tool_results,
318 last_round_assistant: keep.last_round_assistant,
319 dropped_messages: before.saturating_sub(after),
320 anchors_chars: crate::compaction::pinned_anchors_text(Some(&app.workspace))
321 .map(|text| text.chars().count())
322 .unwrap_or(0),
323 // Only the summary path builds a replacement history, so only
324 // it spent a verbatim budget (#5956).
325 retained_user_message_tokens: match path {
326 crate::compaction::CompactionPath::Summary => {
327 app.compaction_retained_user_message_tokens
328 }
329 crate::compaction::CompactionPath::PruneOnly => 0,
330 },
331 operator_instructions_applied: matches!(
332 path,
333 crate::compaction::CompactionPath::Summary
334 ) && app.compaction_summary_instructions.is_some(),
335 },
336 messages_before: before,
337 messages_after: after,
338 });
339 // Automatic maintenance stays in the context inspector and event
340 // receipts; it does not insert a ceremony into the user's task.
341 if !auto {
342 add_compaction_receipt(app, &message);
343 set_explicit_compaction_status(app, message, StatusToastLevel::Success, false);
344 }
345 }
346 }
347
348 pub(crate) fn apply_compaction_failed(app: &mut App, id: &str, auto: bool, message: String) {
349 if settle_compaction(app, id, auto) {
350 add_compaction_receipt(app, &message);
351 // A pass the user asked for keeps its sticky footer error. An
352 // automatic pass is the engine's own recovery: the transcript receipt
353 // records it, and when the turn then fails its error line is the
354 // headline. Echoing the same failure in the footer made one failure
355 // read as three (experience mark 2).
356 if !auto {
357 set_explicit_compaction_status(app, message, StatusToastLevel::Error, true);
358 }
359 }
360 }
361
362 pub(crate) fn apply_compaction_cancelled(app: &mut App, id: &str, auto: bool, message: String) {
363 if settle_compaction(app, id, auto) {
364 add_compaction_receipt(app, &message);
365 set_explicit_compaction_status(app, message, StatusToastLevel::Info, false);
366 }
367 }
368
369 /// Esc/Ctrl+C during a compact that is serving an in-flight turn must stop
370 /// the turn. Compact-only (manual `/compact` with no model request) still
371 /// cancels just the pass.
372 #[must_use]
373 pub(crate) fn compact_interrupt_should_stop_turn(app: &App) -> bool {
374 (app.is_compacting || app.manual_compaction_queued)
375 && (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
376 }
377
378 /// Cancel the exact queued or running pass without cancelling an unrelated
379 /// model turn. A locally deferred request has never entered the engine, so it
380 /// can settle synchronously with no provider call; all dispatched requests
381 /// wait for the authoritative typed terminal event.
382 pub(crate) fn try_cancel_compaction(app: &mut App, engine_handle: &EngineHandle) -> bool {
383 if !app.is_compacting && !app.manual_compaction_queued {
384 return false;
385 }
386
387 if !app.is_compacting && app.deferred_manual_compaction.take().is_some() {
388 app.manual_compaction_queued = false;
389 app.manual_compaction_id = None;
390 let message = "Making room stopped before it started".to_string();
391 add_compaction_receipt(app, &message);
392 set_explicit_compaction_status(app, message, StatusToastLevel::Info, false);
393 return true;
394 }
395
396 let id = app
397 .active_compaction
398 .as_ref()
399 .map(|active| active.id.clone())
400 .or_else(|| app.manual_compaction_id.clone());
401 let Some(id) = id else {
402 return false;
403 };
404
405 match engine_handle.cancel_compaction(id) {
406 Ok(()) => {
407 set_explicit_compaction_status(
408 app,
409 "Stopping making room…".to_string(),
410 StatusToastLevel::Info,
411 false,
412 );
413 }
414 Err(error) => {
415 let message = format!("Could not stop making room: {error}");
416 add_compaction_receipt(app, &message);
417 set_explicit_compaction_status(app, message, StatusToastLevel::Error, true);
418 }
419 }
420 true
421 }
422
423 #[cfg(test)]
424 pub(crate) fn maybe_warn_context_pressure(app: &mut App) {
425 let config = app.compaction_config();
426 maybe_warn_context_pressure_for_config(app, &config);
427 }
428
429 pub(crate) fn maybe_warn_context_pressure_for_config(
430 app: &mut App,
431 config: &crate::compaction::CompactionConfig,
432 ) {
433 if config.enabled {
434 app.dismiss_context_pressure_warning();
435 app.context_pressure_warning_dismissed = None;
436 return;
437 }
438 let max = config.effective_context_window.unwrap_or_else(|| {
439 crate::route_budget::route_context_window_tokens(
440 app.api_provider,
441 app.effective_model_for_budget(),
442 app.active_route_limits,
443 )
444 });
445 let Some((used, max, percent)) = context_usage_snapshot_for_window(app, max) else {
446 return;
447 };
448
449 let configured_threshold = app.auto_compact_threshold_percent.clamp(10.0, 100.0);
450 let warning_threshold = CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(configured_threshold);
451 if percent < warning_threshold {
452 app.context_pressure_warning_dismissed = None;
453 if app.sticky_status.as_ref().is_some_and(|status| {
454 matches!(
455 status.kind,
456 crate::tui::app::StatusToastKind::ContextPressure(_)
457 )
458 }) {
459 app.clear_sticky_status();
460 }
461 app.context_pressure_warning_dismissed = None;
462 return;
463 }
464 let pressure_level = if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
465 crate::context_budget::PressureLevel::Critical
466 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
467 crate::context_budget::PressureLevel::High
468 } else {
469 crate::context_budget::PressureLevel::Medium
470 };
471 if app
472 .context_pressure_warning_dismissed
473 .is_some_and(|dismissed| pressure_level <= dismissed)
474 {
475 return;
476 }
477
478 // #5239: the meter drives real budgets off this window, so an unverified
479 // one must say so next to the numbers that depend on it.
480 let window_note = if app.active_context_window_source.is_verified() {
481 ""
482 } else {
483 ", unverified window"
484 };
485
486 let recommendation = "Making room automatically is off. Turn on auto_compact or use /compact.";
487
488 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
489 set_context_pressure_status(
490 app,
491 format!(
492 "Context critical: {percent:.0}% ({used}/{max} tokens{window_note}). {recommendation}"
493 ),
494 pressure_level,
495 );
496 return;
497 }
498
499 let status_prefix = if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
500 "Context high"
501 } else {
502 "Context building"
503 };
504 set_context_pressure_status(
505 app,
506 format!(
507 "{status_prefix}: {percent:.0}% ({used}/{max} tokens{window_note}). {recommendation}"
508 ),
509 pressure_level,
510 );
511 }
512
513 fn set_context_pressure_status(
514 app: &mut App,
515 text: String,
516 pressure_level: crate::context_budget::PressureLevel,
517 ) {
518 let can_replace = app.sticky_status.as_ref().is_none_or(|status| {
519 matches!(
520 status.kind,
521 crate::tui::app::StatusToastKind::ContextPressure(_)
522 )
523 });
524 if !can_replace {
525 return;
526 }
527 app.status_message = Some(text.clone());
528 app.last_status_message_seen = Some(text.clone());
529 // No TTL: this warning stays visible until compaction or explicit Esc
530 // dismissal instead of being pushed out by later transcript activity.
531 app.sticky_status = Some(crate::tui::app::StatusToast::context_pressure(
532 text,
533 pressure_level,
534 ));
535 app.needs_redraw = true;
536 }
537
538 #[cfg(test)]
539 mod config_update_tests {
540 use super::*;
541 use crate::core::engine::mock_engine_handle;
542 use crate::core::ops::Op;
543
544 #[tokio::test]
545 async fn live_compaction_update_queues_without_waiting_on_engine() {
546 let mut mock = mock_engine_handle();
547 let compaction = crate::compaction::CompactionConfig {
548 enabled: false,
549 token_threshold: 123,
550 model: "deepseek-v4-flash".to_string(),
551 effective_context_window: Some(128_000),
552 cache_summary: true,
553 focus: None,
554 runtime_cost_owner: None,
555 workspace: None,
556 image_input: crate::model_profile::SupportState::Unknown,
557 summary_instructions: None,
558 retained_user_message_tokens:
559 crate::config::DEFAULT_COMPACTION_RETAINED_USER_MESSAGE_TOKENS,
560 };
561
562 assert!(try_apply_model_and_compaction_update(
563 &mock.handle,
564 compaction.clone(),
565 AppMode::Agent,
566 None,
567 ));
568
569 assert!(matches!(
570 mock.rx_op.recv().await,
571 Some(Op::SetModel {
572 model,
573 mode: AppMode::Agent,
574 route_limits: None,
575 }) if model == compaction.model
576 ));
577 assert!(matches!(
578 mock.rx_op.recv().await,
579 Some(Op::SetCompaction { config }) if config == compaction
580 ));
581 }
582
583 /// U03-05: with room for only one op, neither half of the route update
584 /// is queued; a model without its compaction budget is a half-applied
585 /// route with nothing to roll it back.
586 #[tokio::test]
587 async fn live_compaction_update_is_all_or_nothing_when_one_slot_is_free() {
588 let mut mock = mock_engine_handle();
589 let mut fillers = 0;
590 while mock.handle.tx_op.capacity() > 1 {
591 mock.handle
592 .try_send(Op::ListSubAgents)
593 .expect("filler op fits");
594 fillers += 1;
595 }
596 let compaction = crate::compaction::CompactionConfig {
597 model: "deepseek-v4-flash".to_string(),
598 ..crate::compaction::CompactionConfig::default()
599 };
600
601 assert!(!try_apply_model_and_compaction_update(
602 &mock.handle,
603 compaction,
604 AppMode::Agent,
605 None,
606 ));
607
608 for _ in 0..fillers {
609 assert!(matches!(mock.rx_op.try_recv(), Ok(Op::ListSubAgents)));
610 }
611 assert!(
612 mock.rx_op.try_recv().is_err(),
613 "no half of the route update may be queued"
614 );
615 }
616 }
617
617 lines RUST