Retry safety-buffered turns on a forked thread (#33207)

## Why

Retrying a safety-buffered turn by rolling back the active thread rewrites the
source conversation and requires the TUI to repair its local transcript state.

## What changed

- Interrupt the buffered turn, then fork after the preceding completed turn and
  submit the prompt on the new thread with the faster model and low reasoning
  effort.
- Start a new thread when the buffered request is the conversation's first turn.
- Preserve committed steering input in the retry, leave the source thread
  intact, and restore the prompt alongside any existing draft if branching
  fails.
- Do not offer the retry action for side conversations or stale and in-progress
  turns.

## Testing

Added coverage for retries with prior context, first-turn retries, committed
steering input, branch failures, existing composer drafts and attachments, and
side conversations.

GitOrigin-RevId: adb49da8236e9212ef0082e033e6f715986b5cd9
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-15 02:03:46 +00:00
1 parent 77a3f4e80f
commit 9cddda7556
21 files changed
+897 -586

No files matched your search

+23 -16
View File
@@ -144,14 +144,15 @@ async fn safety_buffering_offers_one_retry_with_app_wording() {
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (event_thread_id, event_turn_id, model, turn) = loop {
let (event_thread_id, event_turn_id, model, turn, prompt) = loop {
match rx.try_recv() {
Ok(AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id,
model,
turn,
}) => break (thread_id, turn_id, model, turn),
prompt,
}) => break (thread_id, turn_id, model, turn, prompt),
Ok(_) => continue,
Err(err) => panic!("expected safety-buffering retry event: {err}"),
}
@@ -160,12 +161,32 @@ async fn safety_buffering_offers_one_retry_with_app_wording() {
assert_eq!(event_turn_id, turn_id);
assert_eq!(model, "faster-model");
assert_matches!(turn, Op::UserTurn { .. });
assert_eq!(prompt, UserMessage::from("Explain the request"));
assert!(
!render_bottom_popup(&chat, /*width*/ 80)
.contains("Press enter to confirm or esc to go back")
);
}
#[tokio::test]
async fn safety_buffering_does_not_offer_retry_in_side_conversation() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_side_conversation_active(/*active*/ true);
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
thread_id,
turn_id,
Some("faster-model"),
)),
/*replay_kind*/ None,
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("safety_buffering_side_conversation_without_retry", popup);
}
#[tokio::test]
async fn safety_buffering_remains_visible_until_turn_completes() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -190,20 +211,6 @@ async fn safety_buffering_remains_visible_until_turn_completes() {
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
}
#[tokio::test]
async fn failed_safety_buffering_retry_restores_submitted_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.prepare_safety_buffering_retry();
assert!(chat.input_queue.user_turn_pending_start);
chat.fail_safety_buffering_retry();
assert_eq!(chat.composer_text_with_pending(), "Explain the request");
assert!(!chat.input_queue.user_turn_pending_start);
}
#[tokio::test]
async fn safety_buffering_without_retry_shows_short_app_message() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -735,6 +735,52 @@ async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() {
assert_eq!(chat.remote_image_urls(), remote_image_urls);
}
#[tokio::test]
async fn restored_message_preserves_existing_composer_draft_and_attachments() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let retry_image = PathBuf::from("/tmp/retry.png");
let draft_image = PathBuf::from("/tmp/draft.png");
let paste_placeholder = "[Pasted Content 5 chars]";
let draft_text = format!("[Image #1] {paste_placeholder} draft");
chat.bottom_pane.set_composer_text(
draft_text,
vec![TextElement::new(
(0.."[Image #1]".len()).into(),
Some("[Image #1]".to_string()),
)],
vec![draft_image.clone()],
);
chat.bottom_pane
.set_composer_pending_pastes(vec![(paste_placeholder.to_string(), "hello".to_string())]);
chat.restore_user_message_to_composer(UserMessage {
text: "[Image #1] retry prompt".to_string(),
local_images: vec![LocalImageAttachment {
placeholder: "[Image #1]".to_string(),
path: retry_image.clone(),
}],
remote_image_urls: Vec::new(),
text_elements: vec![TextElement::new(
(0.."[Image #1]".len()).into(),
Some("[Image #1]".to_string()),
)],
mention_bindings: Vec::new(),
});
assert_eq!(
chat.bottom_pane.composer_text(),
format!("[Image #1] retry prompt\n[Image #2] {paste_placeholder} draft")
);
assert_eq!(
chat.bottom_pane.composer_local_image_paths(),
vec![retry_image, draft_image]
);
assert_eq!(
chat.bottom_pane.composer_pending_pastes(),
vec![(paste_placeholder.to_string(), "hello".to_string())]
);
}
#[tokio::test]
async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
@@ -2071,62 +2071,6 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[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;
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-1",
"foo response",
ReplayKind::ThreadSnapshot,
);
replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-2",
"bar response",
ReplayKind::ThreadSnapshot,
);
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;
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-1",
"foo response",
ReplayKind::ThreadSnapshot,
);
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;