mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
fix(tui): drop interrupted stream tails
This commit is contained in:
@@ -1830,6 +1830,8 @@ impl ChatWidget {
|
||||
if let Some(controller) = self.plan_stream_controller.as_mut() {
|
||||
controller.clear_queue();
|
||||
}
|
||||
self.stream_controller = None;
|
||||
self.plan_stream_controller = None;
|
||||
self.clear_active_stream_tail();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1519,6 +1519,48 @@ async fn finalize_turn_persists_structural_plan_tail_as_history_cell() {
|
||||
assert!(chat.plan_stream_controller.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_stream_tail_is_not_persisted_on_finalize_turn() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("prefix already emitted\n".to_string());
|
||||
chat.on_agent_message_delta("preview tail should be dropped\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let active_before = chat
|
||||
.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
active_before.contains("preview tail should be dropped"),
|
||||
"expected preview tail before interrupt, got {active_before:?}",
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt { .. }) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.finalize_turn();
|
||||
|
||||
let inserted = drain_insert_history(&mut rx);
|
||||
let rendered = inserted
|
||||
.iter()
|
||||
.map(|cell| lines_to_single_string(cell))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!rendered.contains("preview tail should be dropped"),
|
||||
"interrupted preview tail should not be persisted, got {rendered:?}",
|
||||
);
|
||||
assert!(chat.stream_controller.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_output_toggle_refreshes_active_stream_tail() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -792,6 +792,29 @@ fn visible_text_source_boundary(source: &str, visible_prefix: &str) -> Option<us
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '&'
|
||||
&& let Some((entity_char, entity_end)) = markdown_entity_at(source, idx)
|
||||
{
|
||||
match visible_chars.peek().copied() {
|
||||
Some(expected) if expected == entity_char => {
|
||||
visible_chars.next();
|
||||
if visible_chars.peek().is_none() {
|
||||
return Some(entity_end);
|
||||
}
|
||||
while let Some((next_idx, _)) = iter.peek().copied() {
|
||||
if next_idx < entity_end {
|
||||
iter.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
at_line_start = false;
|
||||
continue;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '[' {
|
||||
let mut label_end = None;
|
||||
let mut label_chars = Vec::new();
|
||||
@@ -904,6 +927,21 @@ fn skip_line_marker(
|
||||
false
|
||||
}
|
||||
|
||||
fn markdown_entity_at(source: &str, start: usize) -> Option<(char, usize)> {
|
||||
for (entity, ch) in [
|
||||
("&", '&'),
|
||||
("<", '<'),
|
||||
(">", '>'),
|
||||
(""", '"'),
|
||||
("'", '\''),
|
||||
] {
|
||||
if source[start..].starts_with(entity) {
|
||||
return Some((ch, start + entity.len()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn skip_link_destination(source: &str, label_end: usize) -> Option<usize> {
|
||||
let after_label = source.get(label_end..)?;
|
||||
if !after_label.starts_with('(') {
|
||||
@@ -3039,6 +3077,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_set_render_mode_raw_suffix_skips_markdown_entity() {
|
||||
let mut ctrl = stream_controller(Some(/*width*/ 9));
|
||||
ctrl.push("alpha & beta gamma delta epsilon zeta eta theta\n");
|
||||
ctrl.push("tail line\n");
|
||||
|
||||
let (first_emit, idle) = ctrl.on_commit_tick();
|
||||
let first_emit = first_emit
|
||||
.expect("expected first rich wrapped entity emission")
|
||||
.transcript_lines(u16::MAX);
|
||||
assert!(!idle, "expected remaining rich content after one tick");
|
||||
|
||||
ctrl.set_render_mode(HistoryRenderMode::Raw);
|
||||
|
||||
let (cell, _source) = ctrl.finalize();
|
||||
let remaining = cell
|
||||
.map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX)))
|
||||
.unwrap_or_default();
|
||||
let first_emit = lines_to_plain_strings(&first_emit).join("\n");
|
||||
let joined = remaining.join("\n");
|
||||
assert!(
|
||||
!joined.contains("amp;") && !joined.contains("alpha &"),
|
||||
"raw suffix must not resume inside an already-visible markdown entity; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}",
|
||||
);
|
||||
assert!(
|
||||
joined.contains("beta") && joined.contains("tail line"),
|
||||
"raw suffix should preserve text after the markdown entity; emitted before toggle: {first_emit:?}, finalized after toggle: {joined:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_clear_queue_advances_synthetic_accounting_before_finalize() {
|
||||
let mut ctrl = stream_controller(Some(/*width*/ 18));
|
||||
|
||||
Reference in New Issue
Block a user