fix(tui): unaccepted steers queue for the next turn; delete the dead pre-send gate (#6297)

A steer the engine never accepted (turn interrupted; the message rendered
under "Pending inputs / Held for next turn") was moved into a display-only
rejected_steers bucket and never dispatched — no turn, no engine pressure
gate, no compaction, no answer. settle_unaccepted_steers_at_turn_end now
requeues the full QueuedMessage into queued_messages, the one path that
drains into a turn, where the engine's context-pressure gate (turn_loop)
sees it like any other send.

Deleted with it: the cfg(test)-only should_auto_compact_before_send(_with_config)
helpers (zero production callers — a second, fictional authority), the
rejected_steers bucket and its preview row, and the tests that pinned the
fictional path. The fixture assertion in
paused_dispatch_at_compaction_threshold_enqueues_one_atomic_send now states
used >= token_threshold directly.

Gates (remote lane, whale-h100 x86_64 — remote evidence, not local):
- cargo test -p codewhale-tui --lib --locked pending_input: 17 passed; 0 failed
- cargo test -p codewhale-tui --lib --locked steer: 33 passed; 0 failed
- cargo test -p codewhale-tui --lib --locked compaction: 146 passed; 0 failed
- cargo fmt --all -- --check clean.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 05:00:52 -07:00
1 parent 679546c0ba
commit ab1420c51e
7 files changed
+27 -110

No files matched your search

+2 -9
View File
@@ -2268,18 +2268,11 @@ pub struct App {
/// in-flight input uses Ctrl+Enter for same-turn steering and Enter for
/// queued follow-ups; Esc only cancels the active turn.
pub pending_steers: VecDeque<QueuedMessage>,
/// Engine-rejected steers (e.g. a tool was already running and couldn't be
/// cancelled cleanly). Surfaced in the pending-input preview so the user
/// knows the steer was deferred to end-of-turn. Today no engine path
/// produces these; the field is scaffolding for a future signalling
/// channel and the bucket renders with a rejected-steer label when
/// populated.
pub rejected_steers: VecDeque<String>,
/// Steers accepted by the steer channel but not yet seen in the engine's
/// record. Rendered through the same "sending into turn" preview bucket as
/// `pending_steers`; promoted to a transcript cell by
/// `apply_engine_session_projection`, or moved to `rejected_steers` by
/// `TurnComplete` when the turn ended without them (#6190).
/// `apply_engine_session_projection`, or queued as a follow-up by
/// `TurnComplete` when the turn ended without them (#6190, #6297).
pub inflight_steers: VecDeque<InflightSteer>,
/// Legacy resend flag for pending steer recovery.
pub submit_pending_steers_after_interrupt: bool,
-1
View File
@@ -1085,7 +1085,6 @@ impl App {
queued_messages: VecDeque::new(),
queued_draft: None,
pending_steers: VecDeque::new(),
rejected_steers: VecDeque::new(),
inflight_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
turn_started_at: None,
-29
View File
@@ -521,35 +521,6 @@ fn set_context_pressure_status(
app.needs_redraw = true;
}
#[cfg(test)]
pub(crate) fn should_auto_compact_before_send(app: &App) -> bool {
let config = app.compaction_config();
should_auto_compact_before_send_with_config(app, &config)
}
#[cfg(test)]
pub(crate) fn should_auto_compact_before_send_with_config(
app: &App,
config: &crate::compaction::CompactionConfig,
) -> bool {
if !config.enabled {
return false;
}
// Use the same ceiling-anchored token threshold as the engine. Comparing
// against a raw percentage of the input-plus-output window can delay this
// gate until after the spendable input budget has already been exhausted.
let max = config.effective_context_window.unwrap_or_else(|| {
crate::route_budget::route_context_window_tokens(
app.api_provider,
app.effective_model_for_budget(),
app.active_route_limits,
)
});
context_usage_snapshot_for_window(app, max)
.map(|(used, _, _)| used.max(0) as usize >= config.token_threshold)
.unwrap_or(false)
}
#[cfg(test)]
mod config_update_tests {
use super::*;
+8 -7
View File
@@ -1289,17 +1289,18 @@ fn accepted_steer_index(
.map(|(index, _)| index)
}
/// A turn that ended without accepting a steer dropped it. Say so where the
/// steer was already being shown, instead of leaving it "sending" forever or
/// — as before #6190 — leaving a transcript cell for input the model never
/// received.
/// A turn that ended without accepting a steer must not swallow it (#6190,
/// #6297). The message becomes a queued follow-up — the queue is the one path
/// that actually drains into a turn, where the engine's context-pressure gate
/// sees it like any other send — instead of a display-only "rejected" string
/// that nothing ever dispatches.
pub(crate) fn settle_unaccepted_steers_at_turn_end(app: &mut App) {
if app.inflight_steers.is_empty() {
return;
}
for steer in std::mem::take(&mut app.inflight_steers) {
app.rejected_steers.push_back(steer.message.display);
}
let deferred = std::mem::take(&mut app.inflight_steers);
app.queued_messages
.extend(deferred.into_iter().map(|steer| steer.message));
app.needs_redraw = true;
}
+3 -6
View File
@@ -1366,13 +1366,11 @@ pub(crate) fn live_tool_content_is_receipt(content: &str) -> bool {
/// Build the pending-input preview widget from current `App` state.
///
/// v0.6.6 (#122) wires all three buckets:
/// v0.6.6 (#122) wires the live buckets:
/// - `pending_steers` — typed during a running turn + Esc; held until the
/// abort lands and gets resubmitted as a fresh merged turn.
/// - `rejected_steers` — engine declined a mid-turn steer (scaffolding;
/// no engine path produces these yet but the bucket renders with a distinct
/// rejected-steer label).
/// - `queued_messages` — Enter while busy; drained at end-of-turn. In Operate,
/// - `queued_messages` — Enter while busy; drained at end-of-turn. An
/// unaccepted steer also lands here (#6297) so it is never lost. In Operate,
/// the foreground operator dispatches these as additional background tasks.
pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
let mut preview = PendingInputPreview::new();
@@ -1408,7 +1406,6 @@ pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
.chain(app.inflight_steers.iter().map(|steer| &steer.message))
.map(|m| m.display.clone())
.collect();
preview.rejected_steers = app.rejected_steers.iter().cloned().collect();
preview.queued_messages = app
.queued_messages
.iter()
+13 -31
View File
@@ -1,7 +1,6 @@
use super::activity_detail::*;
use super::compaction_flow::{
apply_compaction_started, compact_interrupt_should_stop_turn, maybe_warn_context_pressure,
should_auto_compact_before_send, should_auto_compact_before_send_with_config,
};
use super::event_loop::{TabDispatch, dispatch_tab_key, shell_binding_for_key};
use super::observer_hooks::{
@@ -10659,8 +10658,10 @@ async fn paused_dispatch_at_compaction_threshold_enqueues_one_atomic_send() {
app.api_messages = vec![text_message("assistant", &"context ".repeat(240_000))];
let planned_compaction =
app.compaction_config_for_route(app.api_provider, &app.model, app.active_route_limits);
let fixture_used =
estimated_context_tokens(&app).expect("fixture context estimate should be available");
assert!(
should_auto_compact_before_send_with_config(&app, &planned_compaction),
fixture_used >= planned_compaction.token_threshold as i64,
"fixture must already require compaction"
);
@@ -11872,10 +11873,18 @@ async fn steer_the_turn_never_accepted_is_reported_not_left_in_the_transcript()
"a dropped steer must not leave a transcript cell the record never had"
);
assert!(app.api_messages.is_empty());
assert_eq!(
app.queued_messages
.iter()
.map(|message| message.display.as_str())
.collect::<Vec<_>>(),
vec!["too late to matter"],
"an unaccepted steer is queued for the next turn, never dropped (#6297)"
);
let preview = build_pending_input_preview(&app);
assert!(preview.pending_steers.is_empty());
assert_eq!(
preview.rejected_steers,
preview.queued_messages,
vec!["too late to matter".to_string()]
);
}
@@ -15636,31 +15645,6 @@ fn context_usage_snapshot_prefers_live_estimate_while_loading() {
assert!(percent > 0.0);
}
#[test]
fn should_auto_compact_before_send_uses_shared_token_threshold() {
let mut app = create_test_app();
app.api_messages = vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "context ".repeat(240_000),
cache_control: None,
}],
}];
let (used, _, _) = context_usage_snapshot(&app).expect("context snapshot");
let used = usize::try_from(used).expect("non-negative context estimate");
app.auto_compact = true;
app.compact_threshold = used;
assert!(should_auto_compact_before_send(&app));
app.compact_threshold = used.saturating_add(1);
assert!(!should_auto_compact_before_send(&app));
app.auto_compact = false;
app.compact_threshold = 0;
assert!(!should_auto_compact_before_send(&app));
}
#[test]
fn automatic_compaction_does_not_warn_at_its_threshold() {
let mut app = create_test_app();
@@ -24396,15 +24380,13 @@ fn merged_pending_steers_cross_bounded_message_submit_serialization() {
}
#[test]
fn build_pending_input_preview_populates_all_three_buckets() {
fn build_pending_input_preview_populates_steer_and_queue_buckets() {
let mut app = create_test_app();
app.push_pending_steer(QueuedMessage::new("steer-msg".to_string(), None));
app.rejected_steers.push_back("rejected-msg".to_string());
app.queue_message(QueuedMessage::new("queued-msg".to_string(), None));
let preview = build_pending_input_preview(&app);
assert_eq!(preview.pending_steers, vec!["steer-msg".to_string()]);
assert_eq!(preview.rejected_steers, vec!["rejected-msg".to_string()]);
assert_eq!(preview.queued_messages, vec!["queued-msg".to_string()]);
}
@@ -42,7 +42,6 @@ pub struct PendingInputPreview {
pub locale: Locale,
pub context_items: Vec<ContextPreviewItem>,
pub pending_steers: Vec<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
pub editing_queued_message: Option<String>,
pub edit_binding: EditBinding,
@@ -67,7 +66,6 @@ impl PendingInputPreview {
locale: Locale::En,
context_items: Vec::new(),
pending_steers: Vec::new(),
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
editing_queued_message: None,
edit_binding: EditBinding::UP,
@@ -76,7 +74,6 @@ impl PendingInputPreview {
fn has_pending_inputs(&self) -> bool {
!self.pending_steers.is_empty()
|| !self.rejected_steers.is_empty()
|| !self.queued_messages.is_empty()
|| self.editing_queued_message.is_some()
}
@@ -84,7 +81,6 @@ impl PendingInputPreview {
fn is_queued_only(&self) -> bool {
self.context_items.is_empty()
&& self.pending_steers.is_empty()
&& self.rejected_steers.is_empty()
&& self.editing_queued_message.is_none()
&& !self.queued_messages.is_empty()
}
@@ -167,19 +163,6 @@ impl PendingInputPreview {
&sending_indent,
);
}
let rejected_prefix =
tr(self.locale, MessageId::PendingCouldNotSendIntoTurnPrefix).into_owned();
let rejected_indent = continuation_indent(&rejected_prefix);
for steer in &self.rejected_steers {
push_truncated_item(
&mut lines,
steer,
width,
dim,
&rejected_prefix,
&rejected_indent,
);
}
if let Some(draft) = self.editing_queued_message.as_deref() {
let editing_prefix =
tr(self.locale, MessageId::PendingEditingFollowUpPrefix).into_owned();
@@ -563,7 +546,6 @@ mod tests {
fn all_pending_inputs_render_as_one_list() {
let mut preview = PendingInputPreview::new();
preview.pending_steers.push("steer".to_string());
preview.rejected_steers.push("rejected".to_string());
preview.queued_messages.push("queued".to_string());
let rows = render_to_string(&preview, 60);
assert!(rows[0].contains("Pending inputs"));
@@ -572,7 +554,6 @@ mod tests {
1
);
assert!(rows.iter().any(|r| r.contains("steer")));
assert!(rows.iter().any(|r| r.contains("rejected")));
assert!(rows.iter().any(|r| r.contains("queued")));
assert!(rows.iter().any(|r| r.contains("↑")));
assert!(rows.iter().any(|r| r.contains("Enter send now")));
@@ -582,7 +563,6 @@ mod tests {
fn pending_input_copy_does_not_teach_steer() {
let mut preview = PendingInputPreview::new();
preview.pending_steers.push("please continue".to_string());
preview.rejected_steers.push("too late".to_string());
preview.queued_messages.push("next".to_string());
let joined = render_to_string(&preview, 80)
.join("\n")
@@ -592,14 +572,13 @@ mod tests {
"pending-input copy leaked internal vocabulary: {joined}"
);
assert!(joined.contains("sending into this turn"));
assert!(joined.contains("held for next turn"));
assert!(joined.contains("queued follow-up"));
}
#[test]
fn pending_input_rows_label_each_delivery_mode() {
let mut preview = PendingInputPreview::new();
preview.pending_steers.push("steer".to_string());
preview.rejected_steers.push("rejected".to_string());
preview.queued_messages.push("queued".to_string());
preview.editing_queued_message = Some("editing".to_string());
@@ -610,11 +589,6 @@ mod tests {
.any(|row| row.contains("Sending into this turn: steer")),
"missing pending send-now label: {rows:?}"
);
assert!(
rows.iter()
.any(|row| row.contains("Held for next turn: rejected")),
"missing rejected send-now label: {rows:?}"
);
assert!(
rows.iter()
.any(|row| row.contains("Queued follow-up #1: queued")),