mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
tui: queue non-pending rollback trims in app-event order (#11373)
## Summary
This PR fixes TUI transcript-sync behavior for
`EventMsg::ThreadRolledBack` and makes rollback application order
deterministic.
Previously, rollback handling depended on `pending_rollback`:
- if `pending_rollback` was set (local backtrack), TUI trimmed correctly
- otherwise, replayed/external rollbacks were either ignored or could be
applied at the wrong time relative to queued transcript inserts
This change keeps the local backtrack path intact and routes non-pending
rollbacks through the app event queue so rollback trims are applied in
FIFO order with transcript cell inserts.
## What changed
- Added/used `trim_transcript_cells_drop_last_n_user_turns(...)` for
rollback-by-`num_turns` semantics.
- Renamed rollback app event:
- `AppEvent::ApplyReplayedThreadRollback` ->
`AppEvent::ApplyThreadRollback`
- Replay path (`ChatWidget`) now emits `ApplyThreadRollback`.
- Live non-pending rollback path (`App::handle_backtrack_event`) now
emits `ApplyThreadRollback` instead of trimming immediately.
- App-level event handler applies `ApplyThreadRollback` after queued
`InsertHistoryCell` events and schedules redraw only when a trim
occurred.
- When a trim occurs with an overlay open, TUI now syncs transcript
overlay committed cells, clamps backtrack preview selection, and clears
stale `deferred_history_lines` so closed overlays do not re-append
rolled-back lines.
- Clarified inline comments around the `pending_rollback` branch so
future readers can reason about why there are two paths.
## Why queueing matters
During resume/replay, transcript cells are populated via queued
`InsertHistoryCell` app events. If a rollback is applied immediately
outside that queue, it can run against an incomplete transcript and
under-trim. Queueing non-pending rollbacks ensures consistent ordering
and correct final transcript state.
## Behavior by rollback source
- `pending_rollback = Some(...)` (local backtrack requested by this
TUI):
- use `finish_pending_backtrack()` and the stored selection boundary
- `pending_rollback = None` (replay/external/non-local rollback):
- enqueue `AppEvent::ApplyThreadRollback { num_turns }` and trim in
app-event order
## Tests
Added/updated tests covering ordering and semantics:
-
`app_backtrack::tests::trim_drop_last_n_user_turns_applies_rollback_semantics`
- `app_backtrack::tests::trim_drop_last_n_user_turns_allows_overflow`
- `app::tests::replayed_initial_messages_apply_rollback_in_queue_order`
-
`app::tests::live_rollback_during_replay_is_applied_in_app_event_order`
-
`app::tests::queued_rollback_syncs_overlay_and_clears_deferred_history`
- `chatwidget::tests::replayed_thread_rollback_emits_ordered_app_event`
Validation run:
- `just fmt`
- `cargo test -p codex-tui`
This commit is contained in:
+212
-1
@@ -1537,6 +1537,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||||
|
if self.apply_non_pending_thread_rollback(num_turns) {
|
||||||
|
tui.frame_requester().schedule_frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
AppEvent::StartCommitAnimation => {
|
AppEvent::StartCommitAnimation => {
|
||||||
if self
|
if self
|
||||||
.commit_anim_running
|
.commit_anim_running
|
||||||
@@ -2324,7 +2329,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_codex_event_replay(&mut self, event: Event) {
|
fn handle_codex_event_replay(&mut self, event: Event) {
|
||||||
self.handle_backtrack_event(&event.msg);
|
|
||||||
self.chat_widget.handle_codex_event_replay(event);
|
self.chat_widget.handle_codex_event_replay(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2632,6 +2636,8 @@ mod tests {
|
|||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_core::protocol::SessionConfiguredEvent;
|
use codex_core::protocol::SessionConfiguredEvent;
|
||||||
use codex_core::protocol::SessionSource;
|
use codex_core::protocol::SessionSource;
|
||||||
|
use codex_core::protocol::ThreadRolledBackEvent;
|
||||||
|
use codex_core::protocol::UserMessageEvent;
|
||||||
use codex_otel::OtelManager;
|
use codex_otel::OtelManager;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::user_input::TextElement;
|
use codex_protocol::user_input::TextElement;
|
||||||
@@ -3137,6 +3143,211 @@ mod tests {
|
|||||||
assert_eq!(rollback_turns, Some(1));
|
assert_eq!(rollback_turns, Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replayed_initial_messages_apply_rollback_in_queue_order() {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
|
||||||
|
let session_id = ThreadId::new();
|
||||||
|
app.handle_codex_event_replay(Event {
|
||||||
|
id: String::new(),
|
||||||
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||||
|
session_id,
|
||||||
|
forked_from_id: None,
|
||||||
|
thread_name: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
model_provider_id: "test-provider".to_string(),
|
||||||
|
approval_policy: AskForApproval::Never,
|
||||||
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||||
|
cwd: PathBuf::from("/home/user/project"),
|
||||||
|
reasoning_effort: None,
|
||||||
|
history_log_id: 0,
|
||||||
|
history_entry_count: 0,
|
||||||
|
initial_messages: Some(vec![
|
||||||
|
EventMsg::UserMessage(UserMessageEvent {
|
||||||
|
message: "first prompt".to_string(),
|
||||||
|
images: None,
|
||||||
|
local_images: Vec::new(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}),
|
||||||
|
EventMsg::UserMessage(UserMessageEvent {
|
||||||
|
message: "second prompt".to_string(),
|
||||||
|
images: None,
|
||||||
|
local_images: Vec::new(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}),
|
||||||
|
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||||
|
EventMsg::UserMessage(UserMessageEvent {
|
||||||
|
message: "third prompt".to_string(),
|
||||||
|
images: None,
|
||||||
|
local_images: Vec::new(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
network_proxy: None,
|
||||||
|
rollout_path: Some(PathBuf::new()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut saw_rollback = false;
|
||||||
|
while let Ok(event) = app_event_rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
AppEvent::InsertHistoryCell(cell) => {
|
||||||
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||||
|
app.transcript_cells.push(cell);
|
||||||
|
}
|
||||||
|
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||||
|
saw_rollback = true;
|
||||||
|
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
|
||||||
|
&mut app.transcript_cells,
|
||||||
|
num_turns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(saw_rollback);
|
||||||
|
let user_messages: Vec<String> = app
|
||||||
|
.transcript_cells
|
||||||
|
.iter()
|
||||||
|
.filter_map(|cell| {
|
||||||
|
cell.as_any()
|
||||||
|
.downcast_ref::<UserHistoryCell>()
|
||||||
|
.map(|cell| cell.message.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
user_messages,
|
||||||
|
vec!["first prompt".to_string(), "third prompt".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_rollback_during_replay_is_applied_in_app_event_order() {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
|
||||||
|
let session_id = ThreadId::new();
|
||||||
|
app.handle_codex_event_replay(Event {
|
||||||
|
id: String::new(),
|
||||||
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||||
|
session_id,
|
||||||
|
forked_from_id: None,
|
||||||
|
thread_name: None,
|
||||||
|
model: "gpt-test".to_string(),
|
||||||
|
model_provider_id: "test-provider".to_string(),
|
||||||
|
approval_policy: AskForApproval::Never,
|
||||||
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||||
|
cwd: PathBuf::from("/home/user/project"),
|
||||||
|
reasoning_effort: None,
|
||||||
|
history_log_id: 0,
|
||||||
|
history_entry_count: 0,
|
||||||
|
initial_messages: Some(vec![
|
||||||
|
EventMsg::UserMessage(UserMessageEvent {
|
||||||
|
message: "first prompt".to_string(),
|
||||||
|
images: None,
|
||||||
|
local_images: Vec::new(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}),
|
||||||
|
EventMsg::UserMessage(UserMessageEvent {
|
||||||
|
message: "second prompt".to_string(),
|
||||||
|
images: None,
|
||||||
|
local_images: Vec::new(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
network_proxy: None,
|
||||||
|
rollout_path: Some(PathBuf::new()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate a live rollback arriving before queued replay inserts are drained.
|
||||||
|
app.handle_codex_event_now(Event {
|
||||||
|
id: "live-rollback".to_string(),
|
||||||
|
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut saw_rollback = false;
|
||||||
|
while let Ok(event) = app_event_rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
AppEvent::InsertHistoryCell(cell) => {
|
||||||
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||||
|
app.transcript_cells.push(cell);
|
||||||
|
}
|
||||||
|
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||||
|
saw_rollback = true;
|
||||||
|
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
|
||||||
|
&mut app.transcript_cells,
|
||||||
|
num_turns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(saw_rollback);
|
||||||
|
let user_messages: Vec<String> = app
|
||||||
|
.transcript_cells
|
||||||
|
.iter()
|
||||||
|
.filter_map(|cell| {
|
||||||
|
cell.as_any()
|
||||||
|
.downcast_ref::<UserHistoryCell>()
|
||||||
|
.map(|cell| cell.message.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(user_messages, vec!["first prompt".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
||||||
|
let mut app = make_test_app().await;
|
||||||
|
app.transcript_cells = vec![
|
||||||
|
Arc::new(UserHistoryCell {
|
||||||
|
message: "first".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
}) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(AgentMessageCell::new(
|
||||||
|
vec![Line::from("after first")],
|
||||||
|
false,
|
||||||
|
)) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(UserHistoryCell {
|
||||||
|
message: "second".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
}) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(AgentMessageCell::new(
|
||||||
|
vec![Line::from("after second")],
|
||||||
|
false,
|
||||||
|
)) as Arc<dyn HistoryCell>,
|
||||||
|
];
|
||||||
|
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
||||||
|
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||||
|
app.backtrack.overlay_preview_active = true;
|
||||||
|
app.backtrack.nth_user_message = 1;
|
||||||
|
|
||||||
|
let changed = app.apply_non_pending_thread_rollback(1);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert!(app.backtrack_render_pending);
|
||||||
|
assert!(app.deferred_history_lines.is_empty());
|
||||||
|
assert_eq!(app.backtrack.nth_user_message, 0);
|
||||||
|
let user_messages: Vec<String> = app
|
||||||
|
.transcript_cells
|
||||||
|
.iter()
|
||||||
|
.filter_map(|cell| {
|
||||||
|
cell.as_any()
|
||||||
|
.downcast_ref::<UserHistoryCell>()
|
||||||
|
.map(|cell| cell.message.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(user_messages, vec!["first".to_string()]);
|
||||||
|
let overlay_cell_count = match app.overlay.as_ref() {
|
||||||
|
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
|
||||||
|
_ => panic!("expected transcript overlay"),
|
||||||
|
};
|
||||||
|
assert_eq!(overlay_cell_count, app.transcript_cells.len());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn new_session_requests_shutdown_for_previous_conversation() {
|
async fn new_session_requests_shutdown_for_previous_conversation() {
|
||||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
//! - The first `Esc` in the main view "primes" the feature and captures a base thread id.
|
//! - The first `Esc` in the main view "primes" the feature and captures a base thread id.
|
||||||
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message.
|
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message.
|
||||||
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
|
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
|
||||||
//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and
|
//! - On `EventMsg::ThreadRolledBack`, we either finish an in-flight backtrack request or queue a
|
||||||
//! schedule a one-time scrollback refresh.
|
//! rollback trim so it runs in event order with transcript inserts.
|
||||||
//!
|
//!
|
||||||
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
|
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
|
||||||
//! tail derived from the current in-flight `ChatWidget.active_cell`.
|
//! tail derived from the current in-flight `ChatWidget.active_cell`.
|
||||||
@@ -28,6 +28,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app_event::AppEvent;
|
||||||
use crate::history_cell::SessionInfoCell;
|
use crate::history_cell::SessionInfoCell;
|
||||||
use crate::history_cell::UserHistoryCell;
|
use crate::history_cell::UserHistoryCell;
|
||||||
use crate::pager_overlay::Overlay;
|
use crate::pager_overlay::Overlay;
|
||||||
@@ -453,7 +454,24 @@ impl App {
|
|||||||
|
|
||||||
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
|
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
|
||||||
match event {
|
match event {
|
||||||
EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(),
|
EventMsg::ThreadRolledBack(rollback) => {
|
||||||
|
// `pending_rollback` is set only after this UI sends `Op::ThreadRollback`
|
||||||
|
// from the backtrack flow. In that case, finish immediately using the
|
||||||
|
// stored selection (nth user message) so local trim matches the exact
|
||||||
|
// backtrack target.
|
||||||
|
//
|
||||||
|
// When it is `None`, rollback came from replay or another source. We
|
||||||
|
// queue an AppEvent so rollback trim runs in FIFO order with
|
||||||
|
// `InsertHistoryCell` events, avoiding races with in-flight transcript
|
||||||
|
// inserts.
|
||||||
|
if self.backtrack.pending_rollback.is_some() {
|
||||||
|
self.finish_pending_backtrack();
|
||||||
|
} else {
|
||||||
|
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
|
||||||
|
num_turns: rollback.num_turns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
EventMsg::Error(ErrorEvent {
|
EventMsg::Error(ErrorEvent {
|
||||||
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
||||||
..
|
..
|
||||||
@@ -465,6 +483,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
|
||||||
|
/// in-flight backtrack request (`pending_rollback` is `None`).
|
||||||
|
///
|
||||||
|
/// Returns `true` when local transcript state changed.
|
||||||
|
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
|
||||||
|
if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.sync_overlay_after_transcript_trim();
|
||||||
|
self.backtrack_render_pending = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
|
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
|
||||||
///
|
///
|
||||||
/// We ignore events that do not correspond to the currently active thread to avoid applying
|
/// We ignore events that do not correspond to the currently active thread to avoid applying
|
||||||
@@ -477,8 +508,13 @@ impl App {
|
|||||||
// Ignore rollbacks targeting a prior thread.
|
// Ignore rollbacks targeting a prior thread.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.trim_transcript_for_backtrack(pending.selection.nth_user_message);
|
if trim_transcript_cells_to_nth_user(
|
||||||
self.backtrack_render_pending = true;
|
&mut self.transcript_cells,
|
||||||
|
pending.selection.nth_user_message,
|
||||||
|
) {
|
||||||
|
self.sync_overlay_after_transcript_trim();
|
||||||
|
self.backtrack_render_pending = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
||||||
@@ -508,23 +544,72 @@ impl App {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trim `transcript_cells` to preserve only content before the selected user message.
|
/// Keep transcript-related UI state aligned after `transcript_cells` was trimmed.
|
||||||
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
|
///
|
||||||
trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message);
|
/// This does three things:
|
||||||
|
/// 1. If transcript overlay is open, replace its committed cells so removed turns disappear.
|
||||||
|
/// 2. If backtrack preview is active, clamp/recompute the highlighted user selection.
|
||||||
|
/// 3. Drop deferred transcript lines buffered while overlay was open to avoid flushing lines
|
||||||
|
/// for cells that were just removed by the trim.
|
||||||
|
fn sync_overlay_after_transcript_trim(&mut self) {
|
||||||
|
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||||
|
t.replace_cells(self.transcript_cells.clone());
|
||||||
|
}
|
||||||
|
if self.backtrack.overlay_preview_active {
|
||||||
|
let total_users = user_count(&self.transcript_cells);
|
||||||
|
let next_selection = if total_users == 0 {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
self.backtrack
|
||||||
|
.nth_user_message
|
||||||
|
.min(total_users.saturating_sub(1))
|
||||||
|
};
|
||||||
|
self.apply_backtrack_selection_internal(next_selection);
|
||||||
|
}
|
||||||
|
// While overlay is open, we buffer rendered history lines and flush them on close.
|
||||||
|
// If rollback trimmed cells meanwhile, those buffered lines can reference removed turns.
|
||||||
|
self.deferred_history_lines.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trim_transcript_cells_to_nth_user(
|
fn trim_transcript_cells_to_nth_user(
|
||||||
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
||||||
nth_user_message: usize,
|
nth_user_message: usize,
|
||||||
) {
|
) -> bool {
|
||||||
if nth_user_message == usize::MAX {
|
if nth_user_message == usize::MAX {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
|
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
|
||||||
|
let original_len = transcript_cells.len();
|
||||||
transcript_cells.truncate(cut_idx);
|
transcript_cells.truncate(cut_idx);
|
||||||
|
return transcript_cells.len() != original_len;
|
||||||
}
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trim_transcript_cells_drop_last_n_user_turns(
|
||||||
|
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
||||||
|
num_turns: u32,
|
||||||
|
) -> bool {
|
||||||
|
if num_turns == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_positions: Vec<usize> = user_positions_iter(transcript_cells).collect();
|
||||||
|
let Some(&first_user_idx) = user_positions.first() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
|
||||||
|
let cut_idx = if turns_from_end >= user_positions.len() {
|
||||||
|
first_user_idx
|
||||||
|
} else {
|
||||||
|
user_positions[user_positions.len() - turns_from_end]
|
||||||
|
};
|
||||||
|
let original_len = transcript_cells.len();
|
||||||
|
transcript_cells.truncate(cut_idx);
|
||||||
|
transcript_cells.len() != original_len
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {
|
pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {
|
||||||
@@ -666,4 +751,69 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(between_text, " between");
|
assert_eq!(between_text, " between");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_drop_last_n_user_turns_applies_rollback_semantics() {
|
||||||
|
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||||
|
Arc::new(UserHistoryCell {
|
||||||
|
message: "first".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
}) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(AgentMessageCell::new(
|
||||||
|
vec![Line::from("after first")],
|
||||||
|
false,
|
||||||
|
)) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(UserHistoryCell {
|
||||||
|
message: "second".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
}) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(AgentMessageCell::new(
|
||||||
|
vec![Line::from("after second")],
|
||||||
|
false,
|
||||||
|
)) as Arc<dyn HistoryCell>,
|
||||||
|
];
|
||||||
|
|
||||||
|
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, 1);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(cells.len(), 2);
|
||||||
|
let first_user = cells[0]
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<UserHistoryCell>()
|
||||||
|
.expect("first user");
|
||||||
|
assert_eq!(first_user.message, "first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_drop_last_n_user_turns_allows_overflow() {
|
||||||
|
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||||
|
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
|
||||||
|
as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(UserHistoryCell {
|
||||||
|
message: "first".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
local_image_paths: Vec::new(),
|
||||||
|
}) as Arc<dyn HistoryCell>,
|
||||||
|
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||||
|
as Arc<dyn HistoryCell>,
|
||||||
|
];
|
||||||
|
|
||||||
|
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, u32::MAX);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(cells.len(), 1);
|
||||||
|
let intro = cells[0]
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<AgentMessageCell>()
|
||||||
|
.expect("intro agent");
|
||||||
|
let intro_lines = intro.display_lines(u16::MAX);
|
||||||
|
let intro_text: String = intro_lines[0]
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.content.as_ref())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(intro_text, "• intro");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ pub(crate) enum AppEvent {
|
|||||||
|
|
||||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||||
|
|
||||||
|
/// Apply rollback semantics to local transcript cells.
|
||||||
|
///
|
||||||
|
/// This is emitted when rollback was not initiated by the current
|
||||||
|
/// backtrack flow so trimming occurs in AppEvent queue order relative to
|
||||||
|
/// inserted history cells.
|
||||||
|
ApplyThreadRollback {
|
||||||
|
num_turns: u32,
|
||||||
|
},
|
||||||
|
|
||||||
StartCommitAnimation,
|
StartCommitAnimation,
|
||||||
StopCommitAnimation,
|
StopCommitAnimation,
|
||||||
CommitTick,
|
CommitTick,
|
||||||
|
|||||||
@@ -4041,7 +4041,13 @@ impl ChatWidget {
|
|||||||
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
|
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
|
||||||
EventMsg::CollabResumeBegin(ev) => self.on_collab_event(collab::resume_begin(ev)),
|
EventMsg::CollabResumeBegin(ev) => self.on_collab_event(collab::resume_begin(ev)),
|
||||||
EventMsg::CollabResumeEnd(ev) => self.on_collab_event(collab::resume_end(ev)),
|
EventMsg::CollabResumeEnd(ev) => self.on_collab_event(collab::resume_end(ev)),
|
||||||
EventMsg::ThreadRolledBack(_) => {}
|
EventMsg::ThreadRolledBack(rollback) => {
|
||||||
|
if from_replay {
|
||||||
|
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
|
||||||
|
num_turns: rollback.num_turns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
EventMsg::RawResponseItem(_)
|
EventMsg::RawResponseItem(_)
|
||||||
| EventMsg::ItemStarted(_)
|
| EventMsg::ItemStarted(_)
|
||||||
| EventMsg::AgentMessageContentDelta(_)
|
| EventMsg::AgentMessageContentDelta(_)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ use codex_core::protocol::ReviewTarget;
|
|||||||
use codex_core::protocol::SessionSource;
|
use codex_core::protocol::SessionSource;
|
||||||
use codex_core::protocol::StreamErrorEvent;
|
use codex_core::protocol::StreamErrorEvent;
|
||||||
use codex_core::protocol::TerminalInteractionEvent;
|
use codex_core::protocol::TerminalInteractionEvent;
|
||||||
|
use codex_core::protocol::ThreadRolledBackEvent;
|
||||||
use codex_core::protocol::TokenCountEvent;
|
use codex_core::protocol::TokenCountEvent;
|
||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
use codex_core::protocol::TokenUsageInfo;
|
use codex_core::protocol::TokenUsageInfo;
|
||||||
@@ -1691,6 +1692,26 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replayed_thread_rollback_emits_ordered_app_event() {
|
||||||
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||||
|
|
||||||
|
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||||
|
num_turns: 2,
|
||||||
|
})]);
|
||||||
|
|
||||||
|
let mut saw = false;
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
if let AppEvent::ApplyThreadRollback { num_turns } = event {
|
||||||
|
saw = true;
|
||||||
|
assert_eq!(num_turns, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(saw, "expected replay rollback app event");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn plan_implementation_popup_skips_when_messages_queued() {
|
async fn plan_implementation_popup_skips_when_messages_queued() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||||
|
|||||||
@@ -539,6 +539,26 @@ impl TranscriptOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace committed transcript cells while keeping any cached in-progress output that is
|
||||||
|
/// currently shown at the end of the overlay.
|
||||||
|
///
|
||||||
|
/// This is used when existing history is trimmed (for example after rollback) so the
|
||||||
|
/// transcript overlay immediately reflects the same committed cells as the main transcript.
|
||||||
|
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
|
||||||
|
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||||
|
self.cells = cells;
|
||||||
|
if self
|
||||||
|
.highlight_cell
|
||||||
|
.is_some_and(|idx| idx >= self.cells.len())
|
||||||
|
{
|
||||||
|
self.highlight_cell = None;
|
||||||
|
}
|
||||||
|
self.rebuild_renderables();
|
||||||
|
if follow_bottom {
|
||||||
|
self.view.scroll_offset = usize::MAX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync the active-cell live tail with the current width and cell state.
|
/// Sync the active-cell live tail with the current width and cell state.
|
||||||
///
|
///
|
||||||
/// Recomputes the tail only when the cache key changes, preserving scroll
|
/// Recomputes the tail only when the cache key changes, preserving scroll
|
||||||
@@ -680,6 +700,11 @@ impl TranscriptOverlay {
|
|||||||
pub(crate) fn is_done(&self) -> bool {
|
pub(crate) fn is_done(&self) -> bool {
|
||||||
self.is_done
|
self.is_done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn committed_cell_count(&self) -> usize {
|
||||||
|
self.cells.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct StaticOverlay {
|
pub(crate) struct StaticOverlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user