fix(tui): avoid duplicate live reasoning summaries (#15758)

## TL;DR

Fix duplicated reasoning summaries in `tui_app_server`.

<img width="1716" height="912" alt="image"
src="https://github.com/user-attachments/assets/6362f25a-ab1c-4a01-bf10-b5616c9428c2"
/>

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`.
This commit is contained in:
Felipe Coury
2026-03-25 13:14:39 -03:00
committed by GitHub
Unverified
parent f190a95a4f
commit c6ffe9abab
2 changed files with 58 additions and 5 deletions
+7 -5
View File
@@ -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();
}
@@ -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");