Revert the current thread when editing an earlier TUI prompt (#45845)

## What changed

Use `thread/revert` to remove the selected turn and subsequent history, then restore the selected prompt in the composer. Keep the thread identity, settings, goal, and unrelated thread channels intact. Show a notice that conversation history was reverted and file changes are unchanged.

Resolve selections against persisted history, accounting for partially loaded transcripts and hidden review prompts. Reject stale selections and mid-turn steers. Refresh replay state and pagination after a revert, clear queued input and obsolete actions, and require a session reload when the revert cannot be confirmed.

## Testing

Add regression coverage for reverting earlier and first visible prompts in place, preserving unloaded history and thread state, rejecting steers in clipped history, and clearing streaming output, queued input, and stale approval actions.

GitOrigin-RevId: d3f0342539392bdbfec925d0c3d6f67369937f1b
This commit is contained in:
Eric Traut
2026-09-16 02:57:38 +00:00
committed by copyberry
parent 8ece31a7bf
commit ffae979216
16 changed files with 659 additions and 409 deletions
@@ -1466,15 +1466,18 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() ->
#[tokio::test]
async fn root_switch_preserves_vim_line_yank() -> Result<()> {
let mut app = make_test_app().await;
// Keep the large setup and root-switch futures off the test thread's stack.
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.local_settings.user_config_path.as_path(),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let previous = app_server.start_thread(&app.config).await?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let previous = Box::pin(app_server.start_thread(&app.config)).await?;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target_thread_id = ThreadId::from_string(
@@ -1497,7 +1500,7 @@ async fn root_switch_preserves_vim_line_yank() -> Result<()> {
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id)
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id))
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id));
+5 -5
View File
@@ -1361,7 +1361,7 @@ mod tests {
let default_effort =
app.on_apply_advanced_reasoning("gpt-5.5", ReasoningEffortConfig::Ultra);
let new_thread_config = app.fresh_session_config();
let new_thread_config = &app.config;
assert_eq!(default_effort, Some(expected_default_effort.clone()));
assert_eq!(app.chat_widget.current_model(), "gpt-5.5");
@@ -1372,7 +1372,7 @@ mod tests {
assert_eq!(
(
new_thread_config.model.as_deref(),
new_thread_config.model_reasoning_effort,
new_thread_config.model_reasoning_effort.clone(),
),
(Some("gpt-5.5"), Some(expected_default_effort))
);
@@ -1401,7 +1401,7 @@ mod tests {
let default_effort =
app.on_apply_advanced_reasoning("ultra-only", ReasoningEffortConfig::Ultra);
let new_thread_config = app.fresh_session_config();
let new_thread_config = &app.config;
assert_eq!(default_effort, None);
assert_eq!(app.chat_widget.current_model(), "ultra-only");
@@ -1412,7 +1412,7 @@ mod tests {
assert_eq!(
(
new_thread_config.model.as_deref(),
new_thread_config.model_reasoning_effort,
new_thread_config.model_reasoning_effort.clone(),
),
(Some("gpt-5.5"), Some(ReasoningEffortConfig::Low))
);
@@ -1658,7 +1658,7 @@ enabled = false
)?;
let assert_cloud_requirements = |app: &App| {
let config = app.fresh_session_config();
let config = &app.config;
assert_eq!(
config
.config_layer_stack
+148 -116
View File
@@ -17,6 +17,7 @@ use crate::config_update::format_config_error;
use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome;
use crate::pager_overlay::TranscriptHistoryState;
use crate::session_resume::cwds_differ;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ThreadGoalStatus;
#[cfg(target_os = "windows")]
use codex_app_server_protocol::WindowsSandboxSetupMode;
@@ -36,6 +37,7 @@ impl App {
AppEvent::InsertHistoryCell(_)
| AppEvent::CommitRealtimeTranscriptHistory
| AppEvent::ResetTranscriptForThreadSwitch
| AppEvent::FinishPromptRevert { .. }
| AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AgentsOverviewWorktreeCreated(_)
| AppEvent::AppendMessageHistoryEntry { .. }
@@ -80,7 +82,7 @@ impl App {
mode: crate::app_event::ManagedWorktreeMode::Fork,
..
}
| AppEvent::ForkSessionForPromptEdit { .. }
| AppEvent::RevertSessionForPromptEdit { .. }
| AppEvent::SetThreadGoalDraft { .. }
| AppEvent::SetThreadGoalStatus {
status: ThreadGoalStatus::Active,
@@ -518,7 +520,7 @@ impl App {
self.chat_widget.maybe_send_next_queued_input();
tui.frame_requester().schedule_frame();
}
AppEvent::ForkSessionForPromptEdit {
AppEvent::RevertSessionForPromptEdit {
thread_id,
nth_user_message,
mut prompt,
@@ -534,130 +536,160 @@ impl App {
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "transcript")],
);
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
let config = self.fresh_session_config();
let selected_profile = self.confirmed_server_profile(thread_id);
let turns = match self.thread_event_channels.get(&thread_id) {
Some(channel) => {
let selection: Result<(String, Vec<Turn>)> = async {
let channel = self.thread_event_channels.get(&thread_id)
.ok_or_else(|| color_eyre::eyre::eyre!("the selected thread is no longer available"))?;
let (start_item, loaded_tail, latest_turn_id) = {
let store = channel.store.lock().await;
let mut turns = store.turns.clone();
// Snapshot turns contain loaded history; newer live turns remain in
// the replay buffer and must also be visible to prompt-edit lookups.
for event in &store.buffer {
let ThreadBufferedEvent::Notification(notification) = event else {
continue;
};
match notification.as_ref() {
ServerNotification::TurnStarted(notification)
if !turns
.iter()
.any(|turn| turn.id == notification.turn.id) =>
{
turns.push(notification.turn.clone());
}
ServerNotification::ItemCompleted(notification) => {
if matches!(
notification.item,
ThreadItem::UserMessage { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
) && let Some(turn) = turns
.iter_mut()
.find(|turn| turn.id == notification.turn_id)
&& !turn
.items
.iter()
.any(|item| item.id() == notification.item.id())
{
turn.items.push(notification.item.clone());
}
}
ServerNotification::TurnCompleted(notification) => {
if let Some(turn) = turns
.iter_mut()
.find(|turn| turn.id == notification.turn.id)
{
turn.status = notification.turn.status.clone();
turn.error = notification.turn.error.clone();
turn.started_at = notification.turn.started_at;
turn.completed_at = notification.turn.completed_at;
turn.duration_ms = notification.turn.duration_ms;
}
}
_ => {}
}
}
Some(turns)
(
store.turns.iter().find_map(|turn| turn.items.first()
.map(|item| (turn.id.clone(), item.id().to_string()))),
store.turns.last().map(|turn| turn.id.clone()),
store.latest_turn_id.clone(),
)
};
let mut thread = app_server.thread_read(thread_id, /*include_turns*/ false).await?;
app_server.hydrate_initial_thread_history(
&mut thread,
/*turn_cursor*/ None,
/*item_cursor*/ None,
/*config*/ None,
/*local_settings*/ None,
start_item.as_ref().map(|(turn_id, _)| turn_id).or(loaded_tail.as_ref()).map_or(
crate::app_server_session::HistoryHydrationScope::Complete,
|turn_id| crate::app_server_session::HistoryHydrationScope::ThroughTurn(turn_id),
),
).await?;
if thread.turns.last().map(|turn| &turn.id) != latest_turn_id.as_ref() {
color_eyre::eyre::bail!("thread history changed; reload the session before editing this prompt");
}
None => None,
};
let started = match turns {
Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id(
&turns,
// With no retained visible items, the next prompt follows the metadata-only tail.
let start_item = start_item.or_else(|| loaded_tail.as_ref().and_then(|tail| {
thread.turns.iter().position(|turn| &turn.id == tail).and_then(|index| {
thread.turns[index + 1..].iter().find_map(|turn| turn.items.first()
.map(|item| (turn.id.clone(), item.id().to_string())))
})
}));
let before_turn_id = crate::app_backtrack::backtrack_revert_before_turn_id(
&thread.turns,
start_item.as_ref(),
nth_user_message,
&mut prompt,
) {
Ok(before_turn_id)
if before_turn_id.is_some()
|| app_server.has_older_history(thread_id) =>
{
let before_turn_id = before_turn_id
.or_else(|| turns.first().map(|turn| turn.id.clone()));
app_server
.fork_thread_at(&self.local_settings, config.clone(),
thread_id,
/*last_turn_id*/ None,
before_turn_id,
ForkGoalContinuation::StartIfIdle,
selected_profile.as_ref(),
)
.await
}
Ok(_) => {
app_server
.start_thread_with_session_start_source(
&self.local_settings,
&config, /*session_start_source*/ None,
/*remote_cwd_override*/ None,
selected_profile.as_ref(),
)
.await
}
Err(err) => Err(err),
},
None => Err(color_eyre::eyre::eyre!(
"the selected thread is no longer available for prompt editing"
)),
};
match started {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(
tui,
forked,
ThreadAttachPresentation::PromptEdit,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => self.chat_widget.restore_user_message_to_composer(prompt),
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
)?;
// Keep the store aligned with the displayed prefix, including retained live turns.
let cut = thread.turns.iter().position(|turn| turn.id == before_turn_id)
.ok_or_else(|| color_eyre::eyre::eyre!("selected turn disappeared"))?;
thread.turns.truncate(cut);
if let Some((turn_id, item_id)) = &start_item {
for turn in &mut thread.turns {
if &turn.id == turn_id {
if let Some(index) = turn.items.iter().position(|item| item.id() == item_id) {
turn.items.drain(..index);
}
break;
}
turn.items.clear();
}
}
Ok((before_turn_id, thread.turns))
}.await;
let (before_turn_id, retained_turns) = match selection {
Ok(selection) => selection,
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
self.restore_backtrack_prompt_after_revert_error(prompt, err);
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
};
let reverted = match app_server.revert_thread(thread_id, before_turn_id, &retained_turns).await {
Ok(reverted) => reverted,
Err(err) => {
// Validation and unsupported-method errors leave history unchanged. Other
// failures can arrive after the server has already committed the revert.
if matches!(&err, codex_app_server_client::TypedRequestError::Server { source, .. }
if matches!(source.code, -32602..=-32600)) {
self.restore_backtrack_prompt_after_revert_error(prompt, err);
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
self.chat_widget.restore_user_message_to_composer(prompt);
return Err(color_eyre::Report::new(err).wrap_err("prompt edit could not be confirmed; resume this session to reload its history"));
}
};
self.chat_widget.restore_user_message_to_composer(prompt.clone());
// Stop on any post-mutation failure: accepting input against the old displayed
// transcript would hide the fact that server history has already changed.
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async {
while let Some(event) = app_server.next_event().await {
let is_reverted = matches!(
&event,
AppServerEvent::ServerNotification(notification)
if matches!(notification.as_ref(), ServerNotification::ThreadReverted(notification)
if notification.thread_id == thread_id.to_string())
);
self.handle_app_server_event(app_server, event).await;
if is_reverted {
return Ok(());
}
}
Err(color_eyre::eyre::eyre!("app-server disconnected"))
}).await
.wrap_err("history was reverted, but refreshing the session timed out; resume this session to reload it")??;
// Preserve the widget and unrelated threads; only replace this replay store.
self.chat_widget.restore_thread_input_state(
/*input_state*/ None,
crate::chatwidget::ThreadInputStateRestoreMode { preserve_in_flight_turn: false },
);
self.chat_widget.restore_user_message_to_composer(prompt);
self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ true);
self.abort_thread_event_listener(thread_id);
if let Some(mut rx) = self.active_thread_rx.take() {
while let Ok(event) = rx.try_recv() {
if let ThreadBufferedEvent::Notification(notification) = event {
self.chat_widget.handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot));
}
}
}
tui.frame_requester().schedule_frame();
let mut session = self.thread_event_channels.get(&thread_id)
.ok_or_else(|| color_eyre::eyre::eyre!("reverted thread is no longer available"))?
.store.lock().await.session.clone()
.ok_or_else(|| color_eyre::eyre::eyre!("reverted thread has no session"))?;
session.rollout_path = reverted.thread.path.clone();
if self.primary_thread_id == Some(thread_id) {
self.primary_session_configured = Some(session.clone());
}
self.thread_event_channels.remove(&thread_id);
self.active_thread_id = None;
self.recap.reset_for_new_thread(Instant::now());
self.recap.seed_from_turns(&retained_turns, Instant::now());
self.retain_realtime_replay_state_before_replace();
self.forget_realtime_replay_thread(thread_id);
self.chat_widget.reset_after_prompt_revert(reverted.thread.path, &retained_turns);
self.ensure_thread_channel(thread_id).store.lock().await
.set_session(session, retained_turns);
self.activate_thread_channel(thread_id).await;
// Apply the trim after any transcript inserts produced by shutdown notifications.
// The existing reset barrier keeps terminal input blocked until then.
self.pending_thread_switch_resets += 1;
self.app_event_tx.send(AppEvent::FinishPromptRevert {
thread_id, nth_user_message,
});
}
AppEvent::FinishPromptRevert { thread_id, nth_user_message } => {
self.pending_thread_switch_resets -= 1;
if self.chat_widget.thread_id() == Some(thread_id) {
if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) {
self.transcript_cells.truncate(index);
}
self.deferred_history_lines.clear();
self.last_rendered_history_tail = None;
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
self.backtrack_render_pending = true;
self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ false);
self.chat_widget.emit_prompt_edit_thread_event();
tui.frame_requester().schedule_frame();
}
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
@@ -19,7 +19,6 @@ use std::collections::HashSet;
#[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation {
SessionLineage,
PromptEdit,
}
/// Reports whether a loaded-thread backfill completed and which descendants already had their
@@ -1197,11 +1196,6 @@ impl App {
.adjacent_thread_id(self.current_displayed_thread_id(), direction)
}
pub(super) fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.configured_service_tier();
config
}
pub(super) async fn resume_target_session(
&mut self,
tui: &mut tui::Tui,
+179 -198
View File
@@ -183,7 +183,6 @@ use codex_protocol::protocol::SessionSource as RolloutSessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::user_input::TextElement;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -7284,27 +7283,6 @@ fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() {
);
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;
app.chat_widget.set_service_tier(Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string(),
));
let config = app.fresh_session_config();
assert_eq!(
config.service_tier,
Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string()
)
);
}
#[tokio::test]
async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -7452,11 +7430,11 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
app.apply_backtrack_selection(selection);
let event = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::ForkSessionForPromptEdit { .. }))
.find(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. }))
.expect("prompt edit fork should be requested");
assert_matches!(
event,
AppEvent::ForkSessionForPromptEdit {
AppEvent::RevertSessionForPromptEdit {
thread_id,
nth_user_message,
prompt,
@@ -7477,7 +7455,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
async fn backtrack_branch_failure_restores_selected_prompt_snapshot() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.restore_backtrack_prompt_after_branch_error(
app.restore_backtrack_prompt_after_revert_error(
crate::chatwidget::UserMessage::from("edit this prompt"),
"branch unavailable",
);
@@ -7993,11 +7971,16 @@ async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Resul
}
#[tokio::test]
async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Result<()> {
async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Result<()> {
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::ImageReference;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::user_input::UserInput;
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let filename_ts = "2025-01-05T12-00-00";
let source_thread_id = app_test_support::create_fake_rollout(
let source_thread_id = app_test_support::create_fake_paginated_rollout(
config.codex_home.as_path(),
filename_ts,
"2025-01-05T12:00:00Z",
@@ -8015,6 +7998,7 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
.to_string();
std::fs::write(&source_path, format!("{session_meta}\n"))?;
for (turn_id, message, images, local_images) in [
("turn-0", "older prompt", None, Vec::new()),
("turn-1", "retained prompt", None, Vec::new()),
(
"turn-2",
@@ -8022,7 +8006,31 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
Some(vec!["https://example.com/backtrack.png".to_string()]),
vec![PathBuf::from("/tmp/fake-image.png")],
),
(
"turn-3",
"selected prompt [Image #1]",
Some(vec!["https://example.com/backtrack.png".to_string()]),
vec![PathBuf::from("/tmp/fake-image.png")],
),
] {
let mut content = vec![UserInput::Text {
text: message.to_string(),
text_elements: Vec::new(),
}];
content.extend(
images
.into_iter()
.flatten()
.map(|image_url| UserInput::Image {
image: ImageReference::Inline { image_url },
detail: None,
}),
);
content.extend(
local_images
.into_iter()
.map(|path| UserInput::LocalImage { path, detail: None }),
);
for item in [
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
@@ -8032,11 +8040,16 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: message.to_string(),
images,
local_images,
..Default::default()
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::from_string(&source_thread_id)?,
turn_id: turn_id.to_string(),
item: TurnItem::UserMessage(UserMessageItem {
id: format!("user-{turn_id}"),
client_id: None,
content,
}),
started_at_ms: None,
completed_at_ms: 0,
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
@@ -8054,15 +8067,19 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
// Leave the oldest prompt on an unloaded page.
let mut local_settings = crate::local_settings::LocalSettings::from(&config);
local_settings.tui.terminal_resize_reflow_max_rows = Some(3);
let started = app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&config),
&local_settings,
config.clone(),
source_thread_id,
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
)
.await?;
let selected_turn = started.turns[1].clone();
assert!(app_server.has_older_history(source_thread_id));
let selected_turn = started.turns[3].clone();
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
{
@@ -8073,7 +8090,8 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
.store
.lock()
.await;
store.turns.pop();
// The selected prompt can outlive its bounded replay-buffer entries.
store.turns.truncate(/*len*/ 2);
store.push_notification(turn_started_notification(
source_thread_id,
&selected_turn.id,
@@ -8094,8 +8112,23 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
TurnStatus::Interrupted,
));
}
while app_event_rx.try_recv().is_ok() {}
let source_before = std::fs::read_to_string(&source_path)?;
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
app.transcript_cells.push(Arc::from(cell));
}
}
assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 3);
let child_id = ThreadId::new();
let child = Arc::clone(&app.ensure_thread_channel(child_id).store);
let goal = app_server
.thread_goal_set(
source_thread_id,
Some("Keep this goal".into()),
Some(codex_app_server_protocol::ThreadGoalStatus::Paused),
/*token_budget*/ None,
)
.await?
.goal;
let mut tui = crate::tui::test_support::make_test_tui()?;
let prompt = crate::chatwidget::UserMessage {
text: "selected prompt [Image #1]".to_string(),
@@ -8108,10 +8141,16 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
mention_bindings: Vec::new(),
};
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateModel("gpt-5.4".into()),
))
.await?;
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
AppEvent::RevertSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 1,
prompt: prompt.clone(),
@@ -8119,18 +8158,92 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
))
.await?;
while let Ok(event) = app_event_rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => app.transcript_cells.push(Arc::from(cell)),
event @ AppEvent::FinishPromptRevert { .. } => {
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
}
_ => {}
}
}
assert!(Arc::ptr_eq(
&child,
&app.thread_event_channels[&child_id].store
));
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("Goal paused"));
assert!(matches!(control, AppRunControl::Continue));
let forked_thread_id = app
let reverted_thread_id = app
.chat_widget
.thread_id()
.expect("prompt edit should switch to a forked thread");
assert_ne!(forked_thread_id, source_thread_id);
.expect("prompt edit should keep the current thread");
assert_eq!(reverted_thread_id, source_thread_id);
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt.text);
assert_eq!(
app.chat_widget.remote_image_urls(),
prompt.remote_image_urls
);
assert_eq!(std::fs::read_to_string(&source_path)?, source_before);
assert_eq!(
app_server
.thread_read(reverted_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-0", "turn-1"]
);
let history = app
.transcript_cells
.iter()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 120)))
.collect::<Vec<_>>();
let retained_index = history
.iter()
.position(|line| line.contains("retained prompt"))
.expect("revert should keep the earlier prompt");
let notice_index = history
.iter()
.position(|line| {
line == "• Conversation reverted to this point. File changes are unchanged."
})
.expect("prompt edit should emit the revert notice");
assert!(retained_index < notice_index);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
// Editing the first visible prompt leaves the unloaded prefix intact.
app.chat_widget.restore_thread_input_state(
/*input_state*/ None,
crate::chatwidget::ThreadInputStateRestoreMode {
preserve_in_flight_turn: false,
},
);
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::RevertSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 0,
prompt: "retained prompt".into(),
},
))
.await?;
while let Ok(event) = app_event_rx.try_recv() {
if matches!(event, AppEvent::FinishPromptRevert { .. }) {
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
}
}
assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"retained prompt"
);
assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 0);
assert_eq!(
app_server
.thread_read(source_thread_id, /*include_turns*/ true)
@@ -8139,173 +8252,41 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1", "turn-2"]
vec!["turn-0"]
);
assert_eq!(
app_server
.thread_read(forked_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1"]
);
let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.collect::<Vec<_>>();
let retained_index = history
.iter()
.position(|line| line.contains("retained prompt"))
.expect("forked history should replay the retained prompt");
let notice_index = history
.iter()
.position(|line| line == "• You’re continuing from this point in a new conversation")
.expect("prompt edit should emit the branch notice");
assert!(retained_index < notice_index);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn prompt_edit_before_first_prompt_starts_fresh_thread() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let source_thread_id = app_test_support::create_fake_rollout(
config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"first prompt",
Some("test-provider"),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
std::fs::write(
config.codex_home.join("config.toml"),
"default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n",
)?;
let server_config = ConfigBuilder::default()
.codex_home(config.codex_home.to_path_buf())
.build()
.await?;
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&server_config)).await?;
let started = app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&config),
config.clone(),
let cursor = app_server
.begin_older_history_page(source_thread_id)
.expect("retained prefix should remain pageable after reverting the visible window");
let page = app_server
.thread_items_page(
source_thread_id,
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
/*turn_id*/ None,
Some(cursor.clone()),
/*limit*/ 100,
)
.await?;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.select_permission_profile(
&mut app_server,
PermissionProfileSelection {
profile_id: "server-only".into(),
approval_policy: None,
approvals_reviewer: None,
display_label: "server-only".into(),
},
)
.await;
while app_event_rx.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.handle_event(
app.handle_older_history_page(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 0,
prompt: crate::chatwidget::UserMessage::from("first prompt"),
},
))
.await?;
assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt");
insta::assert_snapshot!(
next_history_message(&mut app_event_rx),
@"■ Wait for permissions to update before editing this prompt."
);
let settings = next_thread_settings_updated(&mut app_server, source_thread_id).await;
app.enqueue_thread_notification(
source_thread_id,
ServerNotification::ThreadSettingsUpdated(settings),
&cursor,
Ok(page),
)
.await?;
while app_event_rx.try_recv().is_ok() {}
let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?;
app.app_server_target = crate::AppServerTarget::Remote { endpoint };
let local_only_root = tempdir()?;
let local_only_root_path = local_only_root.path().abs();
app.harness_overrides
.additional_writable_roots
.push(local_only_root.path().to_path_buf());
app.refresh_in_memory_config_from_disk().await?;
assert!(app.config.workspace_roots.contains(&local_only_root_path));
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 0,
prompt: crate::chatwidget::UserMessage::from("first prompt"),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
let fresh_thread_id = app
.chat_widget
.thread_id()
.expect("first prompt edit should start a fresh thread");
assert_ne!(fresh_thread_id, source_thread_id);
let active = app
.chat_widget
.config_ref()
.permissions
.active_permission_profile()
.unwrap();
assert_eq!(active.id, "server-only");
let session = app
.primary_session_configured
.as_ref()
.expect("new session");
let roots = &session.runtime_workspace_roots;
assert!(!roots.contains(&local_only_root_path));
assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt");
let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
let prompts = app
.transcript_cells
.iter()
.filter_map(|cell| {
cell.as_any()
.downcast_ref::<crate::history_cell::UserHistoryCell>()
})
.map(|cell| cell.message.as_str())
.collect::<Vec<_>>();
assert!(
history.iter().any(|line| {
line == "• You’re continuing from this point in a new conversation"
})
);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
assert_eq!(prompts, vec!["older prompt"]);
assert!(!app_server.has_older_history(source_thread_id));
assert_eq!(
app_server.thread_goal_get(source_thread_id).await?.goal,
Some(goal)
);
app_server.shutdown().await?;
@@ -279,7 +279,7 @@ async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> {
assert!(
std::iter::from_fn(|| app_event_rx.try_recv().ok()).any(|event| matches!(
event,
AppEvent::ForkSessionForPromptEdit {
AppEvent::RevertSessionForPromptEdit {
nth_user_message: 1,
..
}
-6
View File
@@ -1542,9 +1542,6 @@ impl App {
ThreadAttachPresentation::SessionLineage => {
self.chat_widget.handle_thread_session(session);
}
ThreadAttachPresentation::PromptEdit => {
self.chat_widget.handle_prompt_edit_thread_session(session);
}
}
let should_buffer_initial_replay = !turns.is_empty();
let replayed_final_items = realtime_delivery::completed_agent_items_from_turns(&turns);
@@ -1570,9 +1567,6 @@ impl App {
&replayed_final_items,
retained_assistant_captions,
);
if matches!(presentation, ThreadAttachPresentation::PromptEdit) {
self.chat_widget.emit_prompt_edit_thread_event();
}
let pending = std::mem::take(&mut self.pending_primary_events);
for pending_event in pending {
match pending_event {
+133 -51
View File
@@ -4,14 +4,14 @@
//! mediates a key rendering boundary for the transcript overlay.
//!
//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing users
//! to edit an earlier prompt on a source-preserving branch. Confirming a selection forks before
//! the selected turn and restores its prompt in the new composer.
//! to edit an earlier prompt in place. Confirming a selection reverts before
//! the selected turn and restores its prompt in the composer.
//!
//! Backtrack operates as a small state machine:
//! - 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 when
//! there is a prompt to reuse.
//! - `Enter` requests a fork before the selected prompt and reopens it for editing.
//! - `Enter` requests a revert before the selected prompt and reopens it for editing.
//!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
//! tail derived from the current in-flight `ChatWidget.active_cell`.
@@ -74,7 +74,7 @@ pub(crate) struct BacktrackState {
pub(crate) overlay_preview_active: bool,
}
/// A user-visible backtrack choice that can be reopened on a source-preserving branch.
/// A user-visible backtrack choice that can be reopened in the current thread.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct BacktrackSelection {
pub(crate) thread_id: ThreadId,
@@ -117,7 +117,7 @@ impl App {
}
}
/// Request a source-preserving branch before the selected prompt.
/// Revert the current thread before the selected prompt.
pub(crate) fn apply_backtrack_selection(&mut self, selection: BacktrackSelection) {
if self.chat_widget.side_conversation_active() {
self.reset_backtrack_state();
@@ -130,22 +130,22 @@ impl App {
return;
}
self.app_event_tx.send(AppEvent::ForkSessionForPromptEdit {
thread_id: selection.thread_id,
nth_user_message: selection.nth_user_message,
prompt: selection.prompt,
});
self.app_event_tx
.send(AppEvent::RevertSessionForPromptEdit {
thread_id: selection.thread_id,
nth_user_message: selection.nth_user_message,
prompt: selection.prompt,
});
}
pub(crate) fn restore_backtrack_prompt_after_branch_error(
pub(crate) fn restore_backtrack_prompt_after_revert_error(
&mut self,
prompt: UserMessage,
err: impl std::fmt::Display,
) {
self.chat_widget.restore_user_message_to_composer(prompt);
self.chat_widget.add_error_message(format!(
"Failed to branch before the selected prompt: {err}"
));
self.chat_widget
.add_error_message(format!("Failed to edit the selected prompt: {err:#}"));
}
/// Open transcript overlay (enters alternate screen and shows full transcript).
@@ -439,23 +439,63 @@ impl App {
///
/// Replay hides review prompts and other display-empty inputs, so the selected ordinal must be
/// resolved against the same visible projection before restoring its canonical mention bindings.
/// `start_item` anchors a partially loaded transcript within authoritative history; earlier
/// user inputs still establish whether the selected input was a mid-turn steer.
///
/// A turn can contain multiple user messages when it was steered. Only its initial prompt can be
/// reopened independently because app-server cannot fork in the middle of a turn.
pub(crate) fn backtrack_fork_before_turn_id(
/// reopened independently because app-server cannot revert in the middle of a turn.
pub(crate) fn backtrack_revert_before_turn_id(
turns: &[Turn],
start_item: Option<&(String, String)>,
nth_user_message: usize,
prompt: &mut UserMessage,
) -> Result<Option<String>> {
) -> Result<String> {
let (start_turn, start_item) = match start_item {
Some((turn_id, item_id)) => turns
.iter()
.position(|turn| turn.id == *turn_id)
.and_then(|index| {
turns[index]
.items
.iter()
.position(|item| item.id() == item_id)
.map(|item| (index, item))
})
.ok_or_else(|| {
color_eyre::eyre::eyre!("the loaded history boundary no longer exists")
})?,
None => (0, 0),
};
let mut visible_user_messages_seen = 0_usize;
let mut review_mode = false;
for (turn_index, turn) in turns.iter().enumerate() {
let hidden_nested_review_turn = turn_index
.checked_sub(/*rhs*/ 1)
.and_then(|index| turns.get(index))
.is_some_and(|previous| is_hidden_nested_review_turn(previous, turn));
let mut user_messages_in_turn = 0_usize;
for item in &turn.items {
for (turn_index, turn) in turns.iter().enumerate().skip(start_turn) {
let hidden_nested_review_turn = turn_index > start_turn
&& turns.get(turn_index - 1).is_some_and(|previous| {
let previous_items = if turn_index - 1 == start_turn {
&previous.items[start_item..]
} else {
&previous.items
};
is_hidden_nested_review_turn(previous, turn)
&& previous_items
.iter()
.any(|item| matches!(item, ThreadItem::EnteredReviewMode { .. }))
&& previous_items
.iter()
.any(|item| matches!(item, ThreadItem::ExitedReviewMode { .. }))
});
let first_item = if turn_index == start_turn {
start_item
} else {
0
};
// Clipped items affect whether an input is a steer, but were never displayed and
// therefore must not change the transcript's review visibility or prompt ordinals.
let mut user_messages_in_turn = turn.items[..first_item]
.iter()
.filter(|item| matches!(item, ThreadItem::UserMessage { .. }))
.count();
for item in &turn.items[first_item..] {
let content = match item {
ThreadItem::EnteredReviewMode { .. } => {
review_mode = true;
@@ -492,7 +532,7 @@ pub(crate) fn backtrack_fork_before_turn_id(
}
if is_steer {
bail!("the selected prompt is a steer and cannot be branched independently");
bail!("the selected prompt is a steer and cannot be edited independently");
}
if matches!(turn.status, TurnStatus::InProgress) {
bail!("the selected prompt belongs to a turn that is still in progress");
@@ -508,7 +548,7 @@ pub(crate) fn backtrack_fork_before_turn_id(
}
prompt.mention_bindings = mention_bindings_from_user_inputs(content, &display.message);
return Ok((turn_index > 0).then(|| turn.id.clone()));
return Ok(turn.id.clone());
}
}
@@ -667,7 +707,7 @@ mod tests {
}
#[test]
fn backtrack_fork_before_turn_id_resolves_first_and_later_prompts() {
fn backtrack_revert_before_turn_id_resolves_first_and_later_prompts() {
let turns = vec![
turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1),
turn(
@@ -679,48 +719,72 @@ mod tests {
];
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 0,
&mut prompt("turn-1-prompt-0"),
)
.expect("first prompt should resolve"),
None
"turn-1".to_string()
);
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 1,
&mut prompt("turn-2-prompt-0"),
)
.expect("later prompt should resolve"),
Some("turn-2".to_string())
"turn-2".to_string()
);
}
#[test]
fn backtrack_fork_before_turn_id_rejects_mid_turn_steers() {
fn backtrack_revert_before_turn_id_rejects_mid_turn_steers() {
let turns = vec![turn(
"turn-1",
TurnStatus::Completed,
/*user_messages*/ 2,
)];
let error = backtrack_fork_before_turn_id(
let error = backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 1,
&mut prompt("turn-1-prompt-1"),
)
.expect_err("a steer cannot be branched independently");
.expect_err("a steer cannot be edited independently");
assert_eq!(
error.to_string(),
"the selected prompt is a steer and cannot be branched independently"
"the selected prompt is a steer and cannot be edited independently"
);
}
#[test]
fn backtrack_fork_before_turn_id_rejects_in_progress_and_missing_prompts() {
fn backtrack_revert_partial_history_does_not_make_a_steer_editable() {
let turns = vec![turn(
"turn-1",
TurnStatus::Completed,
/*user_messages*/ 2,
)];
let start_item = (turns[0].id.clone(), turns[0].items[1].id().to_string());
let error = backtrack_revert_before_turn_id(
&turns,
Some(&start_item),
/*nth_user_message*/ 0,
&mut prompt("turn-1-prompt-1"),
)
.expect_err("the loaded page starts mid-turn");
assert_eq!(
error.to_string(),
"the selected prompt is a steer and cannot be edited independently"
);
}
#[test]
fn backtrack_revert_before_turn_id_rejects_in_progress_and_missing_prompts() {
let turns = vec![turn(
"turn-1",
TurnStatus::InProgress,
@@ -728,22 +792,24 @@ mod tests {
)];
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 0,
&mut prompt("turn-1-prompt-0"),
)
.expect_err("in-progress prompt cannot be branched")
.expect_err("in-progress prompt cannot be edited")
.to_string(),
"the selected prompt belongs to a turn that is still in progress"
);
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 1,
&mut prompt("missing prompt"),
)
.expect_err("missing prompt cannot be branched")
.expect_err("missing prompt cannot be edited")
.to_string(),
"the selected prompt was not found in the persisted thread"
);
@@ -754,19 +820,20 @@ mod tests {
/*user_messages*/ 1,
)];
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&completed_turns,
/*start_item*/ None,
/*nth_user_message*/ 0,
&mut prompt("different prompt"),
)
.expect_err("a stale transcript prompt cannot be branched")
.expect_err("a stale transcript prompt cannot be edited")
.to_string(),
"the selected transcript prompt no longer matches the persisted thread"
);
}
#[test]
fn backtrack_fork_before_turn_id_skips_hidden_review_prompts() {
fn backtrack_revert_before_turn_id_skips_hidden_review_prompts() {
let mut review_turn = turn(
"turn-review",
TurnStatus::Completed,
@@ -790,18 +857,31 @@ mod tests {
];
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 1,
&mut prompt("turn-2-prompt-0"),
)
.expect("the visible prompt after review should resolve"),
Some("turn-2".to_string())
"turn-2".to_string()
);
// This loaded page starts after the review marker, so its review prompt is visible.
let start_item = (turns[1].id.clone(), turns[1].items[1].id().to_string());
assert_eq!(
backtrack_revert_before_turn_id(
&turns,
Some(&start_item),
/*nth_user_message*/ 1,
&mut prompt("turn-2-prompt-0"),
)
.expect("ordinal must match the clipped transcript"),
"turn-2"
);
}
#[test]
fn backtrack_fork_before_turn_id_skips_hidden_nested_review_prompts() {
fn backtrack_revert_before_turn_id_skips_hidden_nested_review_prompts() {
let review_hint = "current changes";
let review_prompt =
"Review the current code changes (staged, unstaged, and untracked files).";
@@ -859,18 +939,19 @@ mod tests {
];
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 0,
&mut prompt("turn-2-prompt-0"),
)
.expect("the visible prompt after a nested review should resolve"),
Some("turn-2".to_string())
"turn-2".to_string()
);
}
#[test]
fn backtrack_fork_before_turn_id_restores_canonical_mention_bindings() {
fn backtrack_revert_before_turn_id_restores_canonical_mention_bindings() {
let mut selected_turn = turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1);
selected_turn.items = vec![ThreadItem::UserMessage {
id: "selected-prompt".to_string(),
@@ -901,13 +982,14 @@ mod tests {
let mut selected_prompt = prompt("use $skill @sample $google-calendar");
assert_eq!(
backtrack_fork_before_turn_id(
backtrack_revert_before_turn_id(
&turns,
/*start_item*/ None,
/*nth_user_message*/ 1,
&mut selected_prompt,
)
.expect("the selected prompt should resolve"),
Some("turn-2".to_string())
"turn-2".to_string()
);
assert_eq!(
selected_prompt.mention_bindings,
+5 -1
View File
@@ -571,11 +571,15 @@ pub(crate) enum AppEvent {
},
/// Branch before a selected prompt and reopen it in the new thread's composer.
ForkSessionForPromptEdit {
RevertSessionForPromptEdit {
thread_id: ThreadId,
nth_user_message: usize,
prompt: UserMessage,
},
FinishPromptRevert {
thread_id: ThreadId,
nth_user_message: usize,
},
/// Request to exit the application.
///
+51 -2
View File
@@ -15,6 +15,8 @@ use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::ThreadRevertParams;
use codex_app_server_protocol::ThreadRevertResponse;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::Turn;
@@ -28,9 +30,10 @@ pub(crate) const HISTORY_ITEM_PAGE_LIMIT: u32 = 100;
pub(crate) const HISTORY_ITEM_SCAN_LIMIT: usize = 4 * HISTORY_ITEM_PAGE_LIMIT as usize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HistoryHydrationScope {
pub(crate) enum HistoryHydrationScope<'a> {
Initial,
Complete,
ThroughTurn(&'a str),
}
pub(crate) fn thread_items_page_params(
@@ -70,6 +73,41 @@ pub(crate) struct ThreadHistoryPagination {
}
impl AppServerSession {
pub(crate) async fn revert_thread(
&mut self,
thread_id: ThreadId,
before_turn_id: String,
retained_turns: &[Turn],
) -> std::result::Result<ThreadRevertResponse, codex_app_server_client::TypedRequestError> {
let request_id = self.next_request_id();
let response: ThreadRevertResponse = self
.client
.request_typed(ClientRequest::ThreadRevert {
request_id,
params: ThreadRevertParams {
thread_id: thread_id.to_string(),
before_turn_id,
},
})
.await?;
// Older cursors in the retained prefix remain valid. If the entire displayed window
// disappeared, resume paging at the replacement history's end instead.
if retained_turns.iter().all(|turn| turn.items.is_empty()) {
self.history_pagination.insert(
thread_id,
ThreadHistoryPagination {
history_mode: ThreadHistoryMode::Paginated,
next_turn_cursor: response.turns_backwards_cursor.clone(),
next_item_cursor: response.items_backwards_cursor.clone(),
..ThreadHistoryPagination::default()
},
);
} else {
self.cancel_older_history_page(thread_id);
}
Ok(response)
}
pub(crate) fn has_older_history(&self, thread_id: ThreadId) -> bool {
self.history_pagination
.get(&thread_id)
@@ -202,7 +240,7 @@ impl AppServerSession {
item_cursor: Option<String>,
config: Option<&Config>,
local_settings: Option<&crate::local_settings::LocalSettings>,
scope: HistoryHydrationScope,
scope: HistoryHydrationScope<'_>,
) -> Result<()> {
let thread_id = ThreadId::from_string(&thread.id)
.wrap_err("invalid thread id in bounded history response")?;
@@ -231,6 +269,7 @@ impl AppServerSession {
.and_then(|settings| resize_reflow_max_rows(settings.terminal_resize_reflow()));
let item_budget = match (scope, config, row_budget) {
(HistoryHydrationScope::Complete, _, _)
| (HistoryHydrationScope::ThroughTurn(_), _, _)
| (HistoryHydrationScope::Initial, Some(_), None) => None,
(HistoryHydrationScope::Initial, Some(_), Some(max_rows)) => {
Some(max_rows.saturating_add(HISTORY_ITEM_SCAN_LIMIT))
@@ -269,6 +308,16 @@ impl AppServerSession {
let items = self
.merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns)
.await?;
// Finish the anchor turn so prompt editing can detect earlier steers,
// but do not scan history older than the displayed transcript.
if let HistoryHydrationScope::ThroughTurn(anchor) = scope
&& let Some(index) = thread.turns.iter().position(|turn| turn.id == anchor)
&& thread.turns[..index]
.iter()
.any(|turn| !turn.items.is_empty())
{
break;
}
if let Some((config, local_settings)) = config.zip(local_settings) {
rendered_rows = rendered_history_rows(
thread_id,
-1
View File
@@ -869,7 +869,6 @@ pub(crate) enum ReplayKind {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SessionConfiguredDisplay {
Normal,
PromptEdit,
/// Apply session state without emitting the session info cell.
Quiet,
SideConversation,
+55 -15
View File
@@ -158,10 +158,7 @@ impl ChatWidget {
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
let model_for_header = self.current_model().to_string();
if matches!(
display,
SessionConfiguredDisplay::Normal | SessionConfiguredDisplay::PromptEdit
) {
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
@@ -224,16 +221,6 @@ impl ChatWidget {
);
}
pub(crate) fn handle_prompt_edit_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::PromptEdit,
fork_parent_title,
);
}
pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
@@ -275,10 +262,63 @@ impl ChatWidget {
)));
}
/// Clear history-derived state while preserving this thread's settings and goal.
pub(crate) fn reset_after_prompt_revert(
&mut self,
rollout_path: Option<PathBuf>,
retained_turns: &[Turn],
) {
self.current_rollout_path = rollout_path;
self.input_queue.clear();
self.reset_realtime_conversation();
// Finish any output not yet drained from the old runtime without live completion actions.
self.on_task_complete(
/*last_agent_message*/ None, /*completion*/ None, /*from_replay*/ true,
);
self.turn_lifecycle.reset_thread();
self.review = Default::default();
self.transcript.take_active_cell();
self.transcript.reset_copy_history();
self.transcript.reset_turn_flags();
for turn in retained_turns {
let mut replaying_delegation = false;
for item in &turn.items {
if matches!(item, ThreadItem::UserMessage { content, .. }
if realtime::realtime_delegation_input(content).is_some())
{
replaying_delegation = true;
}
if replaying_delegation && realtime::is_private_realtime_agent_item(item) {
continue;
}
let (markdown, source) = match item {
ThreadItem::AgentMessage { text, .. } => (
parse_assistant_markdown(text, self.config.cwd.as_path()).visible_markdown,
text,
),
ThreadItem::Plan { text, .. } => (text.clone(), text),
_ => continue,
};
if !markdown.trim().is_empty() {
self.transcript
.record_agent_markdown(markdown, source.clone());
}
}
}
self.transcript.last_plan_progress = None;
self.last_rendered_user_message_display = None;
self.last_rendered_user_message_client_id = None;
self.clear_pending_rate_limit_reset_hint();
self.set_token_info(/*info*/ None);
self.bottom_pane.clear_pending_questions();
self.bottom_pane.set_task_running(/*running*/ false);
self.refresh_status_surfaces();
}
pub(crate) fn emit_prompt_edit_thread_event(&mut self) {
let line: Line<'static> = vec![
"• ".dim(),
"You’re continuing from this point in a new conversation".into(),
"Conversation reverted to this point. File changes are unchanged.".into(),
]
.into();
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests/history_replay.rs
expression: combined
---
• You’re continuing from this point in a new conversation
• Conversation reverted to this point. File changes are unchanged.
@@ -236,6 +236,21 @@ async fn approving_recent_denial_emits_structured_core_op_once() {
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn prompt_revert_discards_recent_denial_actions() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.on_guardian_assessment(auto_review_denial_event());
chat.reset_after_prompt_revert(/*rollout_path*/ None, &[]);
drain_insert_history(&mut rx);
chat.approve_recent_auto_review_denial(thread_id, "auto-review-recent-1".to_string());
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn guardian_denied_exec_renders_warning_and_denied_request() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -936,6 +936,63 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);
}
#[tokio::test]
async fn prompt_edit_stops_streaming_without_submitting_queued_input() {
let (mut chat, _events, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_message_delta("removed response".into());
chat.queue_user_message("queued follow-up".into());
chat.enter_review_mode_with_hint("review".into(), /*from_replay*/ false);
let retained_turn = AppServerTurn {
items: vec![
AppServerThreadItem::AgentMessage {
id: "retained-response".to_string(),
text: "retained response".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
questions: None,
},
AppServerThreadItem::UserMessage {
id: "voice-steer".to_string(),
client_id: None,
content: vec![UserInput::Text {
text: "<realtime_delegation><input>voice input</input></realtime_delegation>"
.to_string(),
text_elements: Vec::new(),
}],
},
AppServerThreadItem::AgentMessage {
id: "private-commentary".to_string(),
text: "hidden voice commentary".to_string(),
phase: Some(MessagePhase::Commentary),
memory_citation: None,
delivery: None,
questions: None,
},
],
..app_server_turn(
"retained-turn",
AppServerTurnStatus::Completed,
/*duration_ms*/ None,
/*error*/ None,
)
};
chat.reset_after_prompt_revert(/*rollout_path*/ None, &[retained_turn]);
chat.pre_draw_tick();
assert_eq!(
(
chat.is_user_turn_pending_or_running(),
chat.has_active_stream_tail(),
chat.maybe_send_next_queued_input(),
chat.last_agent_markdown_text(),
),
(false, false, false, Some("retained response")),
);
}
#[tokio::test]
async fn prompt_edit_thread_history_line_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -2,4 +2,4 @@
source: tui/src/app/tests.rs
expression: rendered
---
■ Failed to branch before the selected prompt: branch unavailable
■ Failed to edit the selected prompt: branch unavailable