fix(tui): hide empty reasoning summaries (#31652)

## Why

Reasoning summaries can contain an empty HTML comment placeholder, `<!--
-->`, when a generated summary part has no prose. The TUI treated that
placeholder as visible summary content, so it leaked into both the
completed conversation history and persisted transcript. Multi-part
empty summaries could also leave later generated status headings visible
as orphaned transcript content.

## What

- Strip the empty reasoning-summary placeholder at the shared
`ReasoningSummaryCell` boundary.
- Suppress the cell when removing placeholders leaves only generated
bold status headings.
- Cover the multi-part placeholder-only shape with a rendering
regression test for both normal history and transcript output.

## How to Test

1. Start Codex TUI with reasoning summaries visible, for example with
`hide_agent_reasoning = false` and `model_reasoning_summary =
"detailed"`.
2. Run a turn that receives an empty generated reasoning-summary part.
3. Confirm that the in-progress status heading may appear while the turn
is running, but the completed history and transcript contain neither
literal `<!-- -->` tokens nor orphaned reasoning headings.
4. Confirm that a reasoning summary with actual prose still renders
normally.

The model output is nondeterministic, so the targeted regression covers
the exact multi-part placeholder shape from the report:

- `just test -p codex-tui
reasoning_summary_block_hides_empty_html_comment_parts`
This commit is contained in:
Felipe Coury authored and GitHub committed 2026-07-09 16:27:50 +00:00
1 parent 8dc77faf51
commit f46b53bd85
11 files changed
+286 -72

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ impl ChatWidget {
newly_installed_marketplace_tab_id: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_summary_parts: Vec::new(),
status_state: StatusState::default(),
review: ReviewState::default(),
active_hook_cell: None,
+1 -1
View File
@@ -37,7 +37,7 @@ impl ChatWidget {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
+11 -6
View File
@@ -113,13 +113,18 @@ impl ChatWidget {
summary, 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);
let reasoning_parts = summary.into_iter().chain(
self.config
.show_raw_agent_reasoning
.then_some(content)
.into_iter()
.flatten(),
);
for (index, delta) in reasoning_parts.enumerate() {
if index > 0 {
self.on_reasoning_section_break();
}
self.on_agent_reasoning_delta(delta);
}
}
self.on_agent_reasoning_final();
@@ -735,7 +735,7 @@ impl ChatWidget {
);
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
+12 -10
View File
@@ -226,24 +226,26 @@ impl ChatWidget {
pub(super) fn on_agent_reasoning_final(&mut self) {
// At the end of a reasoning block, record transcript-only content.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
if !self.full_reasoning_buffer.is_empty() {
let cell = history_cell::new_reasoning_summary_block(
self.full_reasoning_buffer.clone(),
&self.config.cwd,
);
if !self.reasoning_buffer.is_empty() {
self.reasoning_summary_parts
.push(std::mem::take(&mut self.reasoning_buffer));
}
if !self.reasoning_summary_parts.is_empty() {
let reasoning_parts = std::mem::take(&mut self.reasoning_summary_parts);
let cell = history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
self.add_boxed_history(cell);
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
self.request_redraw();
}
pub(super) fn on_reasoning_section_break(&mut self) {
// Start a new reasoning block for header extraction and accumulate transcript.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
if !self.reasoning_buffer.is_empty() {
self.reasoning_summary_parts
.push(std::mem::take(&mut self.reasoning_buffer));
}
}
pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
@@ -867,7 +867,7 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() {
}
#[tokio::test]
async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
async fn replayed_reasoning_item_preserves_summary_parts_and_hides_raw_reasoning_when_disabled() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.show_raw_agent_reasoning = false;
chat.handle_thread_session(crate::session_state::ThreadSessionState {
@@ -897,7 +897,10 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
chat.replay_thread_item(
AppServerThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["Summary only".to_string()],
summary: vec![
"**Plan**\n\ndone".to_string(),
"**Checking tests**\n\n<!-- -->".to_string(),
],
content: vec!["Raw reasoning".to_string()],
},
"turn-1".to_string(),
@@ -910,7 +913,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
}
other => panic!("expected InsertHistoryCell, got {other:?}"),
};
assert!(!rendered.trim().is_empty());
assert_eq!(rendered, "• done\n");
assert!(!rendered.contains("Raw reasoning"));
}
@@ -1047,6 +1050,82 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
assert_eq!(rendered.matches("Summary only").count(), 1);
}
#[tokio::test]
async fn live_reasoning_summary_drops_empty_parts_without_losing_content() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ 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_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
started_at: Some(0),
completed_at: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
let _ = drain_insert_history(&mut rx);
for (summary_index, delta) in [
(0, "**Plan**\n\ndone"),
(1, "**Checking tests**\n\n<!-- -->"),
] {
chat.handle_server_notification(
ServerNotification::ReasoningSummaryPartAdded(
codex_app_server_protocol::ReasoningSummaryPartAddedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "reasoning-1".to_string(),
summary_index,
},
),
/*replay_kind*/ None,
);
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: delta.to_string(),
summary_index,
}),
/*replay_kind*/ None,
);
}
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: AppServerThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec![
"**Plan**\n\ndone".to_string(),
"**Checking tests**\n\n<!-- -->".to_string(),
],
content: Vec::new(),
},
}),
/*replay_kind*/ None,
);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.transcript_lines(/*width*/ 80))
}
other => panic!("expected InsertHistoryCell, got {other:?}"),
};
assert_eq!(rendered, "• done\n");
}
#[tokio::test]
async fn thread_snapshot_replayed_turn_started_marks_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+1 -1
View File
@@ -80,7 +80,7 @@ impl ChatWidget {
if self.mcp_startup_status.is_none() || !self.status_header_is_mcp_startup_owned() {
self.set_status_header(String::from("Working"));
}
self.full_reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
self.reasoning_buffer.clear();
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Running,