fix(tui/resume): avoid duplicate assistant cells on replay

This commit is contained in:
James Peter
2025-10-07 20:50:32 +10:00
parent 382236f47b
commit cd1aa8dc96
2 changed files with 107 additions and 24 deletions
+21 -24
View File
@@ -15798,40 +15798,37 @@ fi\n\
.is_some()
}) {
// Replace the tail finalized assistant cell if the new content is identical OR
// a superset revision of the previous content (common provider behavior where
// a later final slightly extends the earlier one). Otherwise append a new
// assistant message so distinct messages remain separate.
let (should_replace, _prev_len, _new_len) = self.history_cells[idx]
// a small revision that merely adds leading/trailing context. Otherwise append a
// new assistant message so distinct replies remain separate.
let should_replace = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
.map(|amc| {
let prev = Self::normalize_text(amc.markdown());
let newn = Self::normalize_text(&source);
let identical = prev == newn;
let is_superset = !identical && newn.contains(&prev);
// Heuristic: treat as revision when previous is reasonably long to
// avoid collapsing very short replies unintentionally.
let long_enough = prev.len() >= 80;
(
identical || (is_superset && long_enough),
prev.len(),
newn.len(),
)
if identical || prev.is_empty() {
return identical;
}
let is_prefix_expansion = newn.starts_with(&prev);
let is_suffix_expansion = newn.ends_with(&prev);
let is_large_superset = prev.len() >= 80 && newn.contains(&prev);
identical || is_prefix_expansion || is_suffix_expansion || is_large_superset
})
.unwrap_or((false, 0, 0));
.unwrap_or(false);
if should_replace {
tracing::debug!(
"final-answer: replacing tail AssistantMarkdownCell via heuristic identical/superset"
"final-answer: replacing tail AssistantMarkdownCell via heuristic identical/expansion"
);
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
self.autoscroll_if_near_bottom();
// Final assistant content revised; advance Auto Drive now.
self.auto_on_assistant_final();
return;
}
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
self.autoscroll_if_near_bottom();
// Final assistant content revised; advance Auto Drive now.
self.auto_on_assistant_final();
return;
}
}
// Fallback: no prior assistant cell found; insert at stable sequence position.
+86
View File
@@ -0,0 +1,86 @@
#![cfg(test)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use code_core::protocol::{Event, EventMsg, ReplayHistoryEvent};
use code_protocol::models::{ContentItem, ResponseItem};
use code_tui::test_helpers::{render_chat_widget_to_vt100, ChatWidgetHarness};
fn assistant_cell_count(screen: &str) -> usize {
screen
.lines()
.filter(|line| line.trim_start().starts_with("• "))
.count()
}
fn message(role: &str, text: &str) -> ResponseItem {
let content = match role {
"assistant" => ContentItem::OutputText { text: text.to_string() },
_ => ContentItem::InputText { text: text.to_string() },
};
ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![content],
}
}
#[test]
fn replay_history_duplicates_short_assistant_messages() {
let mut harness = ChatWidgetHarness::new();
let items = vec![
message("user", "Please summarize the plan."),
message("assistant", "Working."),
message("assistant", "Working. Done."),
];
harness.handle_event(Event {
id: "resume-replay".to_string(),
event_seq: 0,
msg: EventMsg::ReplayHistory(ReplayHistoryEvent {
items,
history_snapshot: None,
}),
order: None,
});
let screen = render_chat_widget_to_vt100(&mut harness, 80, 24);
assert_eq!(
1,
assistant_cell_count(&screen),
"expected a single restored assistant message but saw: {screen}"
);
assert!(screen.contains("Working. Done."));
}
#[test]
fn replay_history_handles_prefixed_revisions() {
let mut harness = ChatWidgetHarness::new();
let items = vec![
message("user", "Please summarize the plan."),
message("assistant", "Working."),
message("assistant", "Update:\nWorking."),
];
harness.handle_event(Event {
id: "resume-replay".to_string(),
event_seq: 0,
msg: EventMsg::ReplayHistory(ReplayHistoryEvent {
items,
history_snapshot: None,
}),
order: None,
});
let screen = render_chat_widget_to_vt100(&mut harness, 80, 24);
assert_eq!(
1,
assistant_cell_count(&screen),
"expected a single restored assistant message but saw: {screen}"
);
assert!(screen.contains("Update:"));
}