Prevent replayed runtime events from forcing active status (#12420)

Fixes #11852

Resume replay was applying transient runtime events (`TurnStarted`,
`StreamError`) as if they were live, which could leave the TUI stuck in
a stale `Working` / `Reconnecting...` state after resuming an
interrupted reconnect.

This change makes replay transcript-oriented for these events by:
- skipping retry-status restoration for replayed non-stream events
- ignoring replayed `TurnStarted` for task-running state
- ignoring replayed `StreamError` for reconnect/status UI

Also adds TUI regression tests and snapshot coverage for the interrupted
reconnect replay case.
This commit is contained in:
Eric Traut
2026-02-21 11:55:03 -08:00
committed by GitHub
parent 5a635f3427
commit a6b2bacb5b
3 changed files with 189 additions and 7 deletions

View File

@@ -832,6 +832,12 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplayKind {
ResumeInitialMessages,
ThreadSnapshot,
}
impl ChatWidget {
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
///
@@ -4002,13 +4008,13 @@ impl ChatWidget {
continue;
}
// `id: None` indicates a synthetic/fake id coming from replay.
self.dispatch_event_msg(None, msg, true);
self.dispatch_event_msg(None, msg, Some(ReplayKind::ResumeInitialMessages));
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
self.dispatch_event_msg(Some(id), msg, false);
self.dispatch_event_msg(Some(id), msg, None);
}
pub(crate) fn handle_codex_event_replay(&mut self, event: Event) {
@@ -4016,7 +4022,7 @@ impl ChatWidget {
if matches!(msg, EventMsg::ShutdownComplete) {
return;
}
self.dispatch_event_msg(None, msg, true);
self.dispatch_event_msg(None, msg, Some(ReplayKind::ThreadSnapshot));
}
/// Dispatch a protocol `EventMsg` to the appropriate handler.
@@ -4024,9 +4030,17 @@ impl ChatWidget {
/// `id` is `Some` for live events and `None` for replayed events from
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
/// that must not be used to correlate follow-up actions.
fn dispatch_event_msg(&mut self, id: Option<String>, msg: EventMsg, from_replay: bool) {
fn dispatch_event_msg(
&mut self,
id: Option<String>,
msg: EventMsg,
replay_kind: Option<ReplayKind>,
) {
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
let is_stream_error = matches!(&msg, EventMsg::StreamError(_));
if !is_stream_error {
if !is_resume_initial_replay && !is_stream_error {
self.restore_retry_status_header_if_present();
}
@@ -4061,7 +4075,11 @@ impl ChatWidget {
self.on_agent_reasoning_final();
}
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
EventMsg::TurnStarted(_) => self.on_task_started(),
EventMsg::TurnStarted(_) => {
if !is_resume_initial_replay {
self.on_task_started();
}
}
EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message, ..
}) => self.on_task_complete(last_agent_message, from_replay),
@@ -4151,7 +4169,11 @@ impl ChatWidget {
message,
additional_details,
..
}) => self.on_stream_error(message, additional_details),
}) => {
if !is_resume_initial_replay {
self.on_stream_error(message, additional_details);
}
}
EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);