Preserve streamed output during capped history replay (#33093)

## Why

Stream consolidation can replace transcript cells while initial history replay is buffering capped display rows, leaving those rows stale and omitting the finalized output.

## What changed

- Discard buffered rows when required stream reflow occurs during capped initial replay and rebuild the capped tail from the canonical transcript after replay finishes.
- Schedule the rebuild through the resize reflow path so it waits for an active transcript overlay to close.

## Testing

Added coverage for consolidated Markdown output during capped initial replay, both with and without an active transcript overlay.

GitOrigin-RevId: 1c184ff561a50362daf11c1c629be59882f27a4a
This commit is contained in:
rakan-oai
2026-07-14 15:27:45 +00:00
committed by copyberry
parent 6629e08702
commit effd58d750
4 changed files with 156 additions and 10 deletions
+20 -10
View File
@@ -144,17 +144,14 @@ impl App {
return;
};
if buffer.render_from_transcript_tail || self.overlay.is_some() {
// Reflow clears any pre-replay or partially emitted history and applies the reserved
// history width. It also waits for an active overlay to close before rebuilding.
self.schedule_immediate_resize_reflow(tui);
return;
}
if buffer.retained_lines.is_empty() {
if buffer.render_from_transcript_tail {
let width = tui.terminal.last_known_screen_size.width;
let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines;
if !reflowed_lines.is_empty() {
tui.insert_history_hyperlink_lines_with_wrap_policy(
reflowed_lines,
self.history_line_wrap_policy(),
);
}
}
return;
}
@@ -274,6 +271,19 @@ impl App {
/// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time
/// resize, the visible scrollback can keep the pre-consolidation wrapping.
pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
// Capped initial replay normally buffers per-cell display rows. A live stream tail is
// consolidated directly into `transcript_cells`, so any retained rows no longer describe
// the canonical transcript. Let the replay-end event render the capped transcript tail
// once all consolidation events have been processed.
if self.resize_reflow_max_rows().is_some()
&& let Some(buffer) = self.initial_history_replay_buffer.as_mut()
{
buffer.retained_lines.clear();
buffer.render_from_transcript_tail = true;
self.transcript_reflow.clear_stream_flags();
return Ok(());
}
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
if !self.transcript_reflow.has_pending_reflow() {
@@ -0,0 +1,11 @@
---
source: tui/src/app/tests.rs
expression: "rendered.lines.iter().map(rendered_line_text).collect::<Vec<_>>().join(\"\\n\")"
---
latest user question
• Final answer:
Pattern Outcome
━━━━━━━━━━━━ ━━━━━━━━━━━
Table tail Preserved
@@ -0,0 +1,11 @@
---
source: tui/src/app/tests.rs
expression: "rendered.lines.iter().map(rendered_line_text).collect::<Vec<_>>().join(\"\\n\")"
---
latest user question
• Final answer:
Pattern Outcome
━━━━━━━━━━━━ ━━━━━━━━━━━
Table tail Preserved
+114
View File
@@ -31,6 +31,7 @@ use crate::multi_agents::SubAgentActivityDisplay;
use assert_matches::assert_matches;
use crate::app_command::AppCommand as Op;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::diff_model::FileChange;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
@@ -4561,6 +4562,119 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
);
}
#[tokio::test]
async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(20);
app.transcript_cells = vec![
plain_line_cell("latest user question"),
Arc::new(AgentMarkdownCell::new(
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
.to_string(),
Path::new("/tmp"),
)),
];
app.begin_initial_history_replay_buffer();
App::buffer_initial_history_replay_display_lines(
app.initial_history_replay_buffer
.as_mut()
.expect("initial replay buffer active"),
vec![Line::from("latest user question").into()],
/*max_rows*/ 20,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.finish_required_stream_reflow(&mut tui)?;
let buffer = app
.initial_history_replay_buffer
.as_ref()
.expect("initial replay buffer should remain active");
assert_eq!(
(
buffer.retained_lines.len(),
buffer.render_from_transcript_tail
),
(0, true),
);
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_snapshot!(
"required_stream_reflow_during_capped_initial_replay",
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n")
);
app.finish_initial_history_replay_buffer(&mut tui);
assert!(app.initial_history_replay_buffer.is_none());
assert!(app.transcript_reflow.has_pending_reflow());
Ok(())
}
#[tokio::test]
async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay()
-> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(7);
app.transcript_cells = vec![
plain_line_cell("latest user question"),
Arc::new(AgentMessageCell::new(
vec![Line::from("stale streamed table tail")],
/*is_first_line*/ true,
)),
];
app.begin_initial_history_replay_buffer();
App::buffer_initial_history_replay_display_lines(
app.initial_history_replay_buffer
.as_mut()
.expect("initial replay buffer active"),
vec![Line::from("stale streamed table tail").into()],
/*max_rows*/ 7,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_consolidate_agent_message(
&mut tui,
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
.to_string(),
PathBuf::from("/tmp"),
ConsolidationScrollbackReflow::Required,
/*deferred_history_cell*/ None,
)?;
app.open_transcript_overlay(&mut tui);
assert!(tui.is_alt_screen_active());
app.finish_initial_history_replay_buffer(&mut tui);
assert!(app.initial_history_replay_buffer.is_none());
assert!(app.transcript_reflow.has_pending_reflow());
app.maybe_run_resize_reflow(&mut tui)?;
assert!(app.transcript_reflow.has_pending_reflow());
app.close_transcript_overlay(&mut tui);
assert!(!tui.is_alt_screen_active());
assert!(app.transcript_reflow.has_pending_reflow());
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(rendered.lines.len(), 7);
assert_snapshot!(
"required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay",
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n")
);
Ok(())
}
#[tokio::test]
async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;