Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Coury
35907bb2b8 fix(tui): recover from stale review turn steering race
Handle the stale active-turn race that can happen right after inline
`review/start` completes from the custom review prompt.
Mismatch-shaped `turn/steer` errors are now treated like the existing
"no active turn to steer" recovery path.

Also update the thread event store immediately from successful
`turn/start` and `review/start` responses so fast follow-up composer
input uses the current turn id instead of a stale cached one.
2026-03-31 21:25:38 -03:00
Eric Traut
89ad73d59f codex: fix CI failure on PR #16202 2026-03-29 17:09:32 -06:00
Eric Traut
7e8f6d2f27 Clear completed paste-driven bottom pane views 2026-03-29 16:51:36 -06:00
2 changed files with 138 additions and 6 deletions

View File

@@ -661,6 +661,10 @@ impl ThreadEventStore {
fn clear_active_turn_id(&mut self) {
self.active_turn_id = None;
}
fn set_active_turn_id(&mut self, turn_id: String) {
self.active_turn_id = Some(turn_id);
}
}
#[derive(Debug)]
@@ -1035,6 +1039,8 @@ fn active_turn_missing_steer_error(error: &TypedRequestError) -> bool {
return false;
};
source.message == "no active turn to steer"
|| (source.message.starts_with("expected active turn id `")
&& source.message.contains("` but found `"))
}
impl App {
@@ -1619,6 +1625,14 @@ impl App {
store.active_turn_id().map(ToOwned::to_owned)
}
async fn set_active_turn_id_for_thread(&mut self, thread_id: ThreadId, turn_id: String) {
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
return;
};
let mut store = channel.store.lock().await;
store.set_active_turn_id(turn_id);
}
fn thread_label(&self, thread_id: ThreadId) -> String {
let is_primary = self.primary_thread_id == Some(thread_id);
let fallback_label = if is_primary {
@@ -2102,7 +2116,7 @@ impl App {
}
}
if should_start_turn {
app_server
let response = app_server
.turn_start(
thread_id,
items.to_vec(),
@@ -2120,6 +2134,8 @@ impl App {
final_output_json_schema.clone(),
)
.await?;
self.set_active_turn_id_for_thread(thread_id, response.turn.id)
.await;
}
Ok(true)
}
@@ -2157,9 +2173,11 @@ impl App {
Ok(true)
}
AppCommandView::Review { review_request } => {
app_server
let response = app_server
.review_start(thread_id, review_request.clone())
.await?;
self.set_active_turn_id_for_thread(thread_id, response.turn.id)
.await;
Ok(true)
}
AppCommandView::CleanBackgroundTerminals => {
@@ -8719,6 +8737,17 @@ guardian_approval = true
assert_eq!(store.active_turn_id(), None);
}
#[test]
fn thread_event_store_set_active_turn_id_overrides_cached_turn() {
let mut store = ThreadEventStore::new(/*capacity*/ 8);
let thread_id = ThreadId::new();
store.push_notification(turn_started_notification(thread_id, "turn-1"));
store.set_active_turn_id("turn-2".to_string());
assert_eq!(store.active_turn_id(), Some("turn-2"));
}
#[test]
fn thread_event_store_rebase_preserves_resolved_request_state() {
let thread_id = ThreadId::new();
@@ -8995,6 +9024,23 @@ guardian_approval = true
assert_eq!(active_turn_not_steerable_turn_error(&error), None);
}
#[test]
fn active_turn_missing_steer_error_detects_expected_turn_mismatch_race() {
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message:
"expected active turn id `019d4469-724e-71b1-8c36-444196cc9849` but found `019d4469-721a-7cd3-b642-ffdcd2898a70`"
.to_string(),
data: None,
},
};
assert!(active_turn_missing_steer_error(&error));
assert_eq!(active_turn_not_steerable_turn_error(&error), None);
}
#[test]
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
let mut presets = all_model_presets();

View File

@@ -477,9 +477,14 @@ impl BottomPane {
}
pub fn handle_paste(&mut self, pasted: String) {
if let Some(view) = self.view_stack.last_mut() {
let needs_redraw = view.handle_paste(pasted);
if view.is_complete() {
if !self.view_stack.is_empty() {
let (needs_redraw, view_complete) = {
let last_index = self.view_stack.len() - 1;
let view = &mut self.view_stack[last_index];
(view.handle_paste(pasted), view.is_complete())
};
if view_complete {
self.view_stack.clear();
self.on_active_view_complete();
}
if needs_redraw {
@@ -1280,7 +1285,7 @@ mod tests {
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
disable_paste_burst: true,
animations_enabled: true,
skills: Some(Vec::new()),
});
@@ -1959,4 +1964,85 @@ mod tests {
assert_eq!(handle_calls.get(), 1);
}
#[test]
fn paste_completion_clears_stacked_views_and_restores_composer_input() {
#[derive(Default)]
struct BlockingView {
handle_calls: Rc<Cell<usize>>,
}
impl Renderable for BlockingView {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl BottomPaneView for BlockingView {
fn handle_key_event(&mut self, _key_event: KeyEvent) {
self.handle_calls
.set(self.handle_calls.get().saturating_add(1));
}
}
#[derive(Default)]
struct PasteCompletesView {
complete: bool,
}
impl Renderable for PasteCompletesView {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl BottomPaneView for PasteCompletesView {
fn handle_paste(&mut self, _pasted: String) -> bool {
self.complete = true;
true
}
fn is_complete(&self) -> bool {
self.complete
}
}
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None);
let lower_view_handle_calls = Rc::new(Cell::new(0));
pane.push_view(Box::new(BlockingView {
handle_calls: Rc::clone(&lower_view_handle_calls),
}));
pane.push_view(Box::new(PasteCompletesView::default()));
pane.handle_paste("hello".to_string());
assert!(
pane.view_stack.is_empty(),
"paste completion should tear down the active modal flow"
);
pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let area = Rect::new(0, 0, 40, pane.desired_height(/*width*/ 40).max(2));
assert!(pane.cursor_pos(area).is_some());
assert_eq!(lower_view_handle_calls.get(), 0);
}
}