From c6ffe9abab04bd3349ecc49fffc0fbf9551826e6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 13:14:39 -0300 Subject: [PATCH] fix(tui): avoid duplicate live reasoning summaries (#15758) ## TL;DR Fix duplicated reasoning summaries in `tui_app_server`. image During live turns, reasoning text is already rendered incrementally from `ReasoningSummaryTextDelta`. When the same reasoning item later arrives via `ItemCompleted`, we should only finalize the reasoning block, not render the same summary again. ## What changed - only replay rendered reasoning summaries from completed `ThreadItem::Reasoning` items - kept live completed reasoning items as finalize-only - added a regression test covering the live streaming + completion path ## Why Without this, the first reasoning summary often appears twice in the transcript when `model_reasoning_summary = "detailed"` and `features.tui_app_server = true`. --- codex-rs/tui_app_server/src/chatwidget.rs | 12 +++-- .../tui_app_server/src/chatwidget/tests.rs | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 7a636eded..f3c272d62 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5675,13 +5675,15 @@ impl ChatWidget { ThreadItem::Reasoning { summary, content, .. } => { - for delta in summary { - self.on_agent_reasoning_delta(delta); - } - if self.config.show_raw_agent_reasoning { - for delta in content { + if from_replay { + for delta in summary { self.on_agent_reasoning_delta(delta); } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } } self.on_agent_reasoning_final(); } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 6eefc52a8..98509303e 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; @@ -5135,6 +5136,56 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { assert!(rendered.contains("Raw reasoning")); } +#[tokio::test] +async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.show_welcome_banner = false; + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + None, + ); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: "Summary only".to_string(), + summary_index: 0, + }), + None, + ); + + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["Summary only".to_string()], + content: Vec::new(), + }, + }), + None, + ); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => lines_to_single_string(&cell.transcript_lines(80)), + other => panic!("expected InsertHistoryCell, got {other:?}"), + }; + assert_eq!(rendered.matches("Summary only").count(), 1); +} + #[test] fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png");