mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
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:
1 parent
46e5814f77
commit
cebe57b723
4 files changed
+162
-7
No files matched your search
@@ -473,6 +473,13 @@ fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
const MAX_AGENT_COPY_HISTORY: usize = 32;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AgentTurnMarkdown {
|
||||
user_turn_count: usize,
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateLimitWarningState {
|
||||
@@ -795,13 +802,17 @@ pub(crate) struct ChatWidget {
|
||||
plan_stream_controller: Option<PlanStreamController>,
|
||||
/// Holds the platform clipboard lease so copied text remains available while supported.
|
||||
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
|
||||
/// Raw markdown of the most recently completed agent response.
|
||||
///
|
||||
/// This cache is intentionally best-effort: if the user rolls back the
|
||||
/// thread and then copies before a replacement response arrives, `/copy`
|
||||
/// may still return the response from before the rollback. Keeping this as
|
||||
/// a single cache avoids coupling copy state to the backtrack transcript.
|
||||
/// Raw markdown of the most recently completed agent response that
|
||||
/// survived any local thread rollback.
|
||||
last_agent_markdown: Option<String>,
|
||||
/// Copyable agent responses keyed by the number of visible user turns at
|
||||
/// the time the response completed.
|
||||
agent_turn_markdowns: Vec<AgentTurnMarkdown>,
|
||||
/// Number of user turns currently reflected in the visible transcript.
|
||||
visible_user_turn_count: usize,
|
||||
/// True when rollback discarded the requested copy source because it was
|
||||
/// older than the retained copy history.
|
||||
copy_history_evicted_by_rollback: bool,
|
||||
/// Raw markdown of the most recently completed proposed plan.
|
||||
///
|
||||
/// This is cached only for the approval popup. It is reset at the start of each new task so the
|
||||
@@ -2045,10 +2056,30 @@ impl ChatWidget {
|
||||
if message.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.last_agent_markdown = Some(message.to_string());
|
||||
let markdown = message.to_string();
|
||||
match self.agent_turn_markdowns.last_mut() {
|
||||
Some(entry) if entry.user_turn_count == self.visible_user_turn_count => {
|
||||
entry.markdown = markdown.clone();
|
||||
}
|
||||
_ => {
|
||||
self.agent_turn_markdowns.push(AgentTurnMarkdown {
|
||||
user_turn_count: self.visible_user_turn_count,
|
||||
markdown: markdown.clone(),
|
||||
});
|
||||
if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY {
|
||||
self.agent_turn_markdowns.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_agent_markdown = Some(markdown);
|
||||
self.copy_history_evicted_by_rollback = false;
|
||||
self.saw_copy_source_this_turn = true;
|
||||
}
|
||||
|
||||
fn record_visible_user_turn_for_copy(&mut self) {
|
||||
self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1);
|
||||
}
|
||||
|
||||
// --- Small event handlers ---
|
||||
fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) {
|
||||
self.on_session_configured_with_display(event, SessionConfiguredDisplay::Normal);
|
||||
@@ -2060,6 +2091,9 @@ impl ChatWidget {
|
||||
display: SessionConfiguredDisplay,
|
||||
) {
|
||||
self.last_agent_markdown = None;
|
||||
self.agent_turn_markdowns.clear();
|
||||
self.visible_user_turn_count = 0;
|
||||
self.copy_history_evicted_by_rollback = false;
|
||||
self.saw_copy_source_this_turn = false;
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
@@ -5064,6 +5098,9 @@ impl ChatWidget {
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
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,
|
||||
mcp_startup_expected_servers: None,
|
||||
@@ -5432,6 +5469,23 @@ impl ChatWidget {
|
||||
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
|
||||
&mut self,
|
||||
user_turn_count: usize,
|
||||
) {
|
||||
self.visible_user_turn_count = user_turn_count;
|
||||
let had_copy_history = !self.agent_turn_markdowns.is_empty();
|
||||
self.agent_turn_markdowns
|
||||
.retain(|entry| entry.user_turn_count <= user_turn_count);
|
||||
self.last_agent_markdown = self
|
||||
.agent_turn_markdowns
|
||||
.last()
|
||||
.map(|entry| entry.markdown.clone());
|
||||
self.copy_history_evicted_by_rollback =
|
||||
had_copy_history && self.last_agent_markdown.is_none();
|
||||
self.saw_copy_source_this_turn = false;
|
||||
}
|
||||
|
||||
/// Inner implementation with an injectable clipboard backend for testing.
|
||||
fn copy_last_agent_markdown_with(
|
||||
&mut self,
|
||||
@@ -5450,6 +5504,11 @@ impl ChatWidget {
|
||||
"Copy failed: {error}"
|
||||
))),
|
||||
},
|
||||
_ if self.copy_history_evicted_by_rollback => {
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
|
||||
)));
|
||||
}
|
||||
_ => self.add_to_history(history_cell::new_error_event(
|
||||
"No agent response to copy".into(),
|
||||
)),
|
||||
@@ -5865,6 +5924,7 @@ impl ChatWidget {
|
||||
local_image_paths.clone(),
|
||||
remote_image_urls.clone(),
|
||||
));
|
||||
self.record_visible_user_turn_for_copy();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
text,
|
||||
text_elements,
|
||||
@@ -5879,6 +5939,7 @@ impl ChatWidget {
|
||||
Vec::new(),
|
||||
remote_image_urls.clone(),
|
||||
));
|
||||
self.record_visible_user_turn_for_copy();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
@@ -7248,6 +7309,7 @@ impl ChatWidget {
|
||||
|| !event.text_elements.is_empty()
|
||||
|| !remote_image_urls.is_empty()
|
||||
{
|
||||
self.record_visible_user_turn_for_copy();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
event.message,
|
||||
event.text_elements,
|
||||
|
||||
Reference in new issue
Block a user