diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 861b2d2e4e..9c82e582a9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1066,7 +1066,7 @@ impl ThreadComposerState { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, - pending_steers: VecDeque, + pending_steers: VecDeque, rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, active_turn_id: Option, @@ -1102,6 +1102,7 @@ impl From<&str> for UserMessage { } } +#[derive(Clone, Debug, PartialEq)] struct PendingSteer { user_message: UserMessage, compare_key: PendingSteerCompareKey, @@ -3363,11 +3364,7 @@ impl ChatWidget { }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), - pending_steers: self - .pending_steers - .iter() - .map(|pending| pending.user_message.clone()) - .collect(), + pending_steers: self.pending_steers.clone(), rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), active_turn_id: self @@ -3415,19 +3412,7 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers = input_state - .pending_steers - .into_iter() - .map(|user_message| PendingSteer { - compare_key: PendingSteerCompareKey { - message: user_message.text.clone(), - image_count: user_message.local_images.len() - + user_message.remote_image_urls.len(), - }, - turn_id: input_state.active_turn_id.clone(), - user_message, - }) - .collect(); + self.pending_steers = input_state.pending_steers; self.rejected_steers_queue = input_state.rejected_steers_queue; self.queued_user_messages = input_state.queued_user_messages; } else { @@ -5815,6 +5800,7 @@ impl ChatWidget { duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { + self.last_turn_id = Some(turn_id.clone()); self.last_non_retry_error = None; self.on_task_started(); } @@ -6314,6 +6300,7 @@ impl ChatWidget { notification.error.codex_error_info, Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) ) { + self.queue_unacknowledged_pending_steers_for_turn(¬ification.turn_id); self.finalize_turn(); self.request_redraw(); self.clear_restored_active_turn_if_matches(¬ification.turn_id); @@ -6517,23 +6504,28 @@ impl ChatWidget { TurnStatus::Interrupted => { self.last_non_retry_error = None; if from_replay { - if !replayed_turn_matches_restored_active_turn { - return; + if replayed_turn_matches_restored_active_turn { + self.restore_pending_messages_after_replayed_incomplete_turn(); + self.clear_restored_active_turn_if_matches(&turn_id); + } else { + self.finalize_turn(); + self.request_redraw(); } - self.restore_pending_messages_after_replayed_incomplete_turn(); - self.clear_restored_active_turn_if_matches(&turn_id); } else { self.on_interrupted_turn(TurnAbortReason::Interrupted); } } TurnStatus::Failed => { if from_replay { - if !replayed_turn_matches_restored_active_turn { - return; + if replayed_turn_matches_restored_active_turn { + self.last_non_retry_error = None; + self.restore_pending_messages_after_replayed_incomplete_turn(); + self.clear_restored_active_turn_if_matches(&turn_id); + } else { + self.last_non_retry_error = None; + self.finalize_turn(); + self.request_redraw(); } - self.last_non_retry_error = None; - self.restore_pending_messages_after_replayed_incomplete_turn(); - self.clear_restored_active_turn_if_matches(&turn_id); return; } if let Some(error) = notification.turn.error { diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index e8ef4caf66..a1aaf45c28 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -350,7 +350,9 @@ async fn review_restores_context_window_indicator() { async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut pending_steers = VecDeque::new(); - pending_steers.push_back(UserMessage::from("pending steer")); + let mut pending_steer = pending_steer("pending steer"); + pending_steer.turn_id = Some("turn-1".to_string()); + pending_steers.push_back(pending_steer); let mut rejected_steers_queue = VecDeque::new(); rejected_steers_queue.push_back(UserMessage::from("already rejected")); let mut queued_user_messages = VecDeque::new(); @@ -361,7 +363,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ pending_steers, rejected_steers_queue, queued_user_messages, - active_turn_id: None, + active_turn_id: Some("turn-1".to_string()), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: false, @@ -377,6 +379,10 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.pending_steers.front().unwrap().user_message.text, "pending steer" ); + assert_eq!( + chat.pending_steers.front().unwrap().turn_id.as_deref(), + Some("turn-1") + ); } #[tokio::test] @@ -505,6 +511,29 @@ async fn replayed_completion_does_not_retry_pending_steer() { assert_no_submit_op(&mut op_rx); } +#[tokio::test] +async fn replayed_in_progress_turn_is_captured_as_active_turn() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.replay_thread_turns( + vec![AppServerTurn { + id: "active-turn".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + ReplayKind::ThreadSnapshot, + ); + + let input_state = chat + .capture_thread_input_state() + .expect("expected thread input state"); + assert_eq!(input_state.active_turn_id.as_deref(), Some("active-turn")); +} + #[tokio::test] async fn replayed_user_message_acknowledges_pending_steer_only_for_restored_turn() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;