mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
679546c0ba
commit
ab1420c51e
7 files changed
+27
-110
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user