From deb159d9ffb848b60a9c63ab9d2ce014d7093b0f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:00:57 -0700 Subject: [PATCH] Fix TUI stream cleanup after turn errors (#23128) ## Summary Fixes #22726. After a Responses stream disconnect, the live TUI could keep accepting prompts while leaving partially streamed assistant output in its transient streaming-cell form. That made fenced diffs or SVG/XML-like content appear as raw transcript text until the user closed the TUI and resumed the same session, which rebuilt the transcript from saved history. This change finalizes the active answer stream before generic failed-turn cleanup clears the stream controller, so the live transcript takes the same source-backed markdown consolidation path as a successful turn. ## Reviewer repro 1. Start a local Codex TUI session. 2. Trigger an assistant turn that streams markdown content, especially a fenced diff or SVG/XML-like block. 3. Force or encounter a non-retry stream disconnect before the turn completes. 4. Continue using the same still-open TUI session. 5. Before this fix, the live history can stay raw/plain even though `codex resume` renders the same session normally. 6. After this fix, the failed-turn path consolidates the partial stream before rendering the error, so the live TUI keeps normal transcript rendering. --- .../tui/src/chatwidget/tests/app_server.rs | 34 +++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 1 + 2 files changed, 35 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 26e38900ce..688e30bd17 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -636,6 +636,40 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn live_app_server_failed_turn_consolidates_streamed_answer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_turn_started(&mut chat, "turn-1"); + while rx.try_recv().is_ok() {} + + handle_agent_message_delta(&mut chat, "```diff\n+ streamed patch\n```\n"); + chat.run_commit_tick(); + while rx.try_recv().is_ok() {} + + handle_error( + &mut chat, + "stream disconnected before completion", + /*codex_error_info*/ None, + ); + + let mut saw_consolidate = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::ConsolidateAgentMessage { source, .. } = event { + saw_consolidate = true; + assert!( + source.contains("streamed patch"), + "expected partial stream source to be consolidated, got {source:?}" + ); + } + } + + assert!( + saw_consolidate, + "failed turn should consolidate streamed cells before clearing the stream controller" + ); +} + #[tokio::test] async fn live_app_server_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index d82eca338f..4868d6a25c 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -339,6 +339,7 @@ impl ChatWidget { pub(super) fn on_error(&mut self, message: String) { self.input_queue.submit_pending_steers_after_interrupt = false; + self.flush_answer_stream_with_separator(); self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.set_ambient_pet_notification(