fix(tui): keep /copy aligned with rollback (#18739)

## Why

Fixes #18718.

After rewinding a thread, `/copy` could still copy the latest assistant
response from before the rewind. The transcript cells were rolled back,
but the copy source was a single `last_agent_markdown` cache that was
not synchronized with backtracking, so the visible conversation and
copied content could diverge.

## What changed

`ChatWidget` now keeps a bounded copy history for the most recent 32
assistant responses, keyed by the visible user-turn count. When local
rollback trims transcript cells, the copy cache is trimmed to the same
surviving user-turn count so `/copy` uses the latest visible assistant
response.

If the user rewinds past the retained copy window, `/copy` now reports:

```text
Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy.
```

The change also adds coverage for copying the latest surviving response
after rollback and for the over-limit rewind message.

## Verification

- Manually resumed a synthetic 35-turn session, rewound within the
retained window, and verified `/copy` copied the surviving response.
- Manually rewound past the retained window and verified `/copy` showed
the 32-response limit message.
- `cargo test -p codex-tui slash_copy`
- `just fix -p codex-tui`
- `cargo insta pending-snapshots`

Note: `cargo test -p codex-tui` currently fails on unrelated model
catalog and snapshot drift around the default model changing to
`gpt-5.4`; the focused `/copy` tests pass after fixing the new test
setup.
This commit is contained in:
Felipe Coury
2026-04-20 19:24:10 -03:00
committed by GitHub
Unverified
parent 46e5814f77
commit cebe57b723
4 changed files with 162 additions and 7 deletions
@@ -212,6 +212,9 @@ pub(super) async fn make_chatwidget_manual(
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
last_agent_markdown: None,
agent_turn_markdowns: Vec::new(),
visible_user_turn_count: 0,
copy_history_evicted_by_rollback: false,
latest_proposed_plan_markdown: None,
saw_copy_source_this_turn: false,
running_commands: HashMap::new(),
@@ -1029,6 +1029,92 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
);
}
#[tokio::test]
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "user-1".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "foo".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "foo response".to_string(),
phase: None,
memory_citation: None,
}),
});
chat.handle_codex_event_replay(Event {
id: "user-2".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "bar".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-2".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "bar response".to_string(),
phase: None,
memory_citation: None,
}),
});
let _ = drain_insert_history(&mut rx);
assert_eq!(chat.last_agent_markdown_text(), Some("bar response"));
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 1);
assert_eq!(chat.last_agent_markdown_text(), Some("foo response"));
chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "foo response");
Ok(None)
});
}
#[tokio::test]
async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "user-1".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "foo".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "foo response".to_string(),
phase: None,
memory_citation: None,
}),
});
let _ = drain_insert_history(&mut rx);
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0);
chat.dispatch_command(SlashCommand::Copy);
let cells = drain_insert_history(&mut rx);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(
"Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy."
),
"expected evicted-history message, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_exit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;