diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9c82e582a9..993789d6d6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5800,6 +5800,8 @@ impl ChatWidget { duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { + self.restored_active_turn_id + .get_or_insert_with(|| turn_id.clone()); self.last_turn_id = Some(turn_id.clone()); self.last_non_retry_error = None; self.on_task_started(); @@ -6212,7 +6214,15 @@ impl ChatWidget { } } ServerNotification::TurnStarted(notification) => { - self.last_turn_id = Some(notification.turn.id); + let turn_id = notification.turn.id; + if from_replay { + if self.restored_active_turn_id.is_none() { + self.restored_active_turn_id = Some(turn_id.clone()); + } else if !self.replayed_turn_matches_restored_active_turn(&turn_id) { + return; + } + } + self.last_turn_id = Some(turn_id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { self.on_task_started(); @@ -6296,10 +6306,18 @@ impl ChatWidget { if self.replayed_turn_is_stale_for_restored_input(¬ification.turn_id) { return; } - if !matches!( + if matches!( notification.error.codex_error_info, Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) ) { + if self.pending_steers.front().is_some_and(|pending| { + pending.turn_id.as_deref().is_none_or(|pending_turn_id| { + pending_turn_id == notification.turn_id + }) + }) { + self.enqueue_rejected_steer(); + } + } else { self.queue_unacknowledged_pending_steers_for_turn(¬ification.turn_id); self.finalize_turn(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index a1aaf45c28..d76a55cec5 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -532,6 +532,25 @@ async fn replayed_in_progress_turn_is_captured_as_active_turn() { .capture_thread_input_state() .expect("expected thread input state"); assert_eq!(input_state.active_turn_id.as_deref(), Some("active-turn")); + assert_eq!(chat.restored_active_turn_id.as_deref(), Some("active-turn")); + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "older-turn".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + Some(ReplayKind::ThreadSnapshot), + ); + + assert_eq!(chat.last_turn_id.as_deref(), Some("active-turn")); } #[tokio::test] @@ -565,6 +584,39 @@ async fn replayed_user_message_acknowledges_pending_steer_only_for_restored_turn assert!(chat.pending_steers.is_empty()); } +#[tokio::test] +async fn replayed_steer_rejection_queues_matching_pending_steer() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut pending = pending_steer("retry after rejection"); + pending.turn_id = Some("active-turn".to_string()); + chat.pending_steers.push_back(pending); + chat.restored_active_turn_id = Some("active-turn".to_string()); + + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + message: "cannot steer this turn".to_string(), + codex_error_info: Some( + codex_app_server_protocol::CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: codex_app_server_protocol::NonSteerableTurnKind::Review, + }, + ), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "active-turn".to_string(), + }), + Some(ReplayKind::ThreadSnapshot), + ); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["retry after rejection"] + ); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;