Require confirmation for safety-buffered retries (#42380)

## What changed

- Show a confirmation before stopping a safety-buffered attempt and retrying
  with the server-selected faster model.
- Explain that the retry starts a new thread, preserves actions already taken,
  and may use a less capable model; allow the user to keep waiting instead.
- Dismiss the buffering or confirmation view when the response starts or the
  turn is no longer eligible for retry.
- Refresh the buffering copy and wrap confirmation text correctly in narrow
  terminals.

## Testing

- Cover retry confirmation, cancellation, turn completion, response startup,
  stale updates, and narrow-terminal rendering.

GitOrigin-RevId: 013bc47174811dd104b421774577c2808c732bfa
This commit is contained in:
Eric Traut
2026-09-02 21:34:01 +00:00
committed by copyberry
parent 69cebb5d15
commit 0588fc941c
12 changed files with 287 additions and 34 deletions

View File

@@ -770,6 +770,16 @@ impl App {
tracing::error!(error = ?err, "failed to start turn through app server");
}
}
AppEvent::ConfirmSafetyBufferedRetry {
thread_id,
turn_id,
model,
turn,
prompt,
} => {
self.chat_widget
.confirm_safety_buffered_retry(thread_id, turn_id, model, turn, prompt);
}
AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id,

View File

@@ -41,10 +41,6 @@ impl App {
return;
}
if !self.chat_widget.can_retry_safety_buffered_turn(&turn_id) {
self.app_event_tx.send(AppEvent::UpdateModel(model));
self.app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(
ReasoningEffortConfig::Low,
)));
return;
}

View File

@@ -714,6 +714,13 @@ goals = true
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let second_retry = loop {
match app_event_rx.try_recv() {
Ok(event @ AppEvent::ConfirmSafetyBufferedRetry { .. }) => {
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
Ok(AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id,

View File

@@ -300,6 +300,15 @@ pub(crate) enum AppEvent {
op: AppCommand,
},
/// Confirm retrying a safety-buffered turn with the server-selected model.
ConfirmSafetyBufferedRetry {
thread_id: ThreadId,
turn_id: String,
model: String,
turn: AppCommand,
prompt: UserMessage,
},
/// Interrupt, fork, and retry a safety-buffered turn with the server-selected model.
RetrySafetyBufferedTurn {
thread_id: ThreadId,

View File

@@ -1,16 +1,33 @@
//! Safety-buffering status and retry UI for active turns.
//! Safety-buffering status and retry confirmation UI for active turns.
//! Both views share an ID so response streaming, completion, or buffering updates dismiss either one.
use super::*;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
const SAFETY_BUFFERING_PROMPT_VIEW_ID: &str = "safety-buffering-prompt";
const SAFETY_BUFFERING_LEARN_MORE_URL: &str = "https://help.openai.com/en/articles/20001326";
const SAFETY_BUFFERING_HEADER: &str =
"Our systems are thinking a bit more about this request before responding.";
const SAFETY_BUFFERING_MESSAGE_WITH_RETRY: &str = "Hang tight or retry with a faster model for a quicker response, though it may be less capable of handling complex requests.";
const SAFETY_BUFFERING_HEADER: &str = "Giving this request a little extra thought";
const SAFETY_BUFFERING_MESSAGE_WITH_RETRY: &str = "If you'd rather not wait, retry with a faster model. It may be less capable of handling complex requests.";
const SAFETY_BUFFERING_FOOTER: &str = "No action is required. Codex will keep waiting, and this menu will close when the response is ready.";
struct SafetyBufferingHeader(Vec<Line<'static>>);
impl Renderable for SafetyBufferingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
Renderable::render(
&Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))),
area,
buf,
);
}
fn desired_height(&self, width: u16) -> u16 {
word_wrap_lines(&self.0, usize::from(width)).len() as u16
}
}
#[derive(Debug)]
struct ActiveSafetyBuffering {
turn_id: String,
@@ -42,8 +59,13 @@ impl ChatWidget {
}
pub(super) fn mark_safety_buffering_agent_message_started(&mut self) {
if let Some(active) = self.safety_buffering.active.as_mut() {
if let Some(active) = self.safety_buffering.active.as_mut()
&& !active.agent_message_started
{
active.agent_message_started = true;
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.restore_reasoning_status_header();
}
}
@@ -93,6 +115,11 @@ impl ChatWidget {
if matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages))
|| !self.turn_lifecycle.agent_turn_running
|| self.turn_lifecycle.last_turn_id.as_deref() != Some(turn_id.as_str())
|| self
.safety_buffering
.active
.as_ref()
.is_some_and(|active| active.turn_id == turn_id && active.agent_message_started)
{
return;
}
@@ -135,12 +162,10 @@ impl ChatWidget {
.filter(|active| active.turn_id == turn_id);
let should_show_prompt =
previous_active.is_none_or(|active| active.last_prompt_had_retry != can_offer_retry);
let agent_message_started =
previous_active.is_some_and(|active| active.agent_message_started);
self.safety_buffering.active = Some(ActiveSafetyBuffering {
turn_id: turn_id.clone(),
last_prompt_had_retry: can_offer_retry,
agent_message_started,
agent_message_started: false,
});
let status_details = if can_offer_retry {
@@ -162,16 +187,10 @@ impl ChatWidget {
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
let mut header = vec![Box::new(
Paragraph::new(Line::from(SAFETY_BUFFERING_HEADER).bold()).wrap(Wrap { trim: false }),
) as Box<dyn Renderable>];
let mut header = vec![Line::from(SAFETY_BUFFERING_HEADER).bold()];
if can_offer_retry {
header.push(Box::new(
Paragraph::new(Line::from(SAFETY_BUFFERING_MESSAGE_WITH_RETRY).dim())
.wrap(Wrap { trim: false }),
));
header.push(Line::from(SAFETY_BUFFERING_MESSAGE_WITH_RETRY).dim());
}
let header = ColumnRenderable::with(header);
let mut items = Vec::new();
if let (Some(faster_model), Some(turn), Some(prompt), Some(thread_id)) =
(faster_model, retry_turn, retry_prompt, thread_id)
@@ -179,7 +198,7 @@ impl ChatWidget {
items.push(SelectionItem {
name: "Retry with a faster model".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::RetrySafetyBufferedTurn {
tx.send(AppEvent::ConfirmSafetyBufferedRetry {
thread_id,
turn_id: turn_id.clone(),
model: faster_model.clone(),
@@ -209,11 +228,68 @@ impl ChatWidget {
]);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
header: Box::new(header),
header: Box::new(SafetyBufferingHeader(header)),
footer_note: Some(Line::from(SAFETY_BUFFERING_FOOTER).dim()),
footer_hint: Some(Line::default()),
items,
..Default::default()
});
}
pub(crate) fn confirm_safety_buffered_retry(
&mut self,
thread_id: ThreadId,
turn_id: String,
model: String,
turn: AppCommand,
prompt: UserMessage,
) {
if self.thread_id != Some(thread_id) || !self.can_retry_safety_buffered_turn(&turn_id) {
return;
}
let model_name = self
.model_catalog
.try_list_models()
.ok()
.and_then(|models| models.into_iter().find(|preset| preset.model == model))
.map(|preset| preset.display_name)
.unwrap_or_else(|| model.clone());
self.bottom_pane
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
header: Box::new(SafetyBufferingHeader(vec![
"Stop this attempt and retry?".bold().into(),
Line::default(),
"This will stop the current attempt and retry in a new thread. Any file changes or other actions already taken will remain.".dim().into(),
Line::default(),
format!("Your message will be sent again using {model_name}, which may be less capable on complex tasks.").dim().into(),
])),
footer_hint: Some(Line::default()),
items: vec![
SelectionItem {
name: "Keep waiting".to_string(),
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Stop and retry".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::RetrySafetyBufferedTurn {
thread_id,
turn_id: turn_id.clone(),
model: model.clone(),
turn: turn.clone(),
prompt: prompt.clone(),
});
})],
dismiss_on_select: true,
require_explicit_confirmation: true,
..Default::default()
},
],
..Default::default()
});
}
}

View File

@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: "render_bottom_popup(&chat, 80)"
---
Stop this attempt and retry?
This will stop the current attempt and retry in a new thread. Any file
changes or other actions already taken will remain.
Your message will be sent again using Faster Model, which may be less
capable on complex tasks.
1. Keep waiting
2. Stop and retry

View File

@@ -0,0 +1,17 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: "render_bottom_popup(&chat, 40)"
---
Stop this attempt and retry?
This will stop the current attempt
and retry in a new thread. Any file
changes or other actions already
taken will remain.
Your message will be sent again
using Faster Model, which may be
less capable on complex tasks.
1. Keep waiting
2. Stop and retry

View File

@@ -2,9 +2,9 @@
source: tui/src/chatwidget/tests/app_server.rs
expression: popup
---
Our systems are thinking a bit more about this request before responding.
Hang tight or retry with a faster model for a quicker response, though it
may be less capable of handling complex requests.
Giving this request a little extra thought
If you'd rather not wait, retry with a faster model. It may be less capable
of handling complex requests.
1. Retry with a faster model
2. Dismiss and keep waiting

View File

@@ -2,7 +2,7 @@
source: tui/src/chatwidget/tests/app_server.rs
expression: popup
---
Our systems are thinking a bit more about this request before responding.
Giving this request a little extra thought
1. Dismiss and keep waiting
2. Learn more

View File

@@ -2,7 +2,7 @@
source: tui/src/chatwidget/tests/app_server.rs
expression: popup
---
Our systems are thinking a bit more about this request before responding.
Giving this request a little extra thought
1. Dismiss and keep waiting
2. Learn more

View File

@@ -4,8 +4,7 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use pretty_assertions::assert_eq;
const SAFETY_BUFFERING_HEADER_TEXT: &str =
"Our systems are thinking a bit more about this request before responding.";
const SAFETY_BUFFERING_HEADER_TEXT: &str = "Giving this request a little extra thought";
fn thread_settings_for_test(
model: &str,
@@ -113,9 +112,41 @@ fn safety_buffering_notification(
}
}
fn open_safety_buffering_retry_confirmation(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
loop {
match rx.try_recv() {
Ok(AppEvent::ConfirmSafetyBufferedRetry {
thread_id,
turn_id,
model,
turn,
prompt,
}) => {
chat.confirm_safety_buffered_retry(thread_id, turn_id, model, turn, prompt);
break;
}
Ok(AppEvent::RetrySafetyBufferedTurn { .. }) => {
panic!("retry must wait for confirmation");
}
Ok(_) => continue,
Err(err) => panic!("expected safety-buffering confirmation event: {err}"),
}
}
assert!(chat.turn_lifecycle.agent_turn_running);
assert!(render_bottom_popup(chat, /*width*/ 80).contains("Stop this attempt and retry?"));
}
#[tokio::test]
async fn safety_buffering_offers_one_retry_with_app_wording() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.model = "faster-model".to_string();
preset.display_name = "Faster Model".to_string();
chat.model_catalog = Arc::new(ModelCatalog::new(vec![preset]));
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
@@ -146,6 +177,21 @@ 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));
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
assert_chatwidget_snapshot!(
"safety_buffering_retry_confirmation",
render_bottom_popup(&chat, /*width*/ 80)
);
assert_chatwidget_snapshot!(
"safety_buffering_retry_confirmation_narrow",
render_bottom_popup(&chat, /*width*/ 40)
);
assert!(op_rx.try_recv().is_err());
while let Ok(event) = rx.try_recv() {
assert!(!matches!(event, AppEvent::RetrySafetyBufferedTurn { .. }));
}
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (event_thread_id, event_turn_id, model, turn, prompt) = loop {
match rx.try_recv() {
@@ -171,6 +217,60 @@ async fn safety_buffering_offers_one_retry_with_app_wording() {
);
}
#[tokio::test]
async fn safety_buffering_retry_confirmation_can_keep_waiting() {
for key in [KeyCode::Enter, KeyCode::Esc] {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
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,
);
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
assert!(chat.can_retry_safety_buffered_turn(turn_id));
assert!(op_rx.try_recv().is_err());
while let Ok(event) = rx.try_recv() {
assert!(!matches!(event, AppEvent::RetrySafetyBufferedTurn { .. }));
}
}
}
#[tokio::test]
async fn safety_buffering_retry_confirmation_closes_when_turn_completes() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, turn) = 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,
);
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
handle_turn_completed(&mut chat, turn_id, /*duration_ms*/ None);
// A queued request to open the confirmation must not reopen it after completion.
chat.confirm_safety_buffered_retry(
thread_id,
turn_id.to_string(),
"faster-model".to_string(),
turn,
UserMessage::from("Explain the request"),
);
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
}
#[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;
@@ -191,8 +291,8 @@ async fn safety_buffering_does_not_offer_retry_in_side_conversation() {
}
#[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;
async fn safety_buffering_retry_confirmation_closes_when_response_starts() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
@@ -203,11 +303,30 @@ async fn safety_buffering_remains_visible_until_turn_completes() {
/*replay_kind*/ None,
);
assert!(chat.can_retry_safety_buffered_turn(turn_id));
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
chat.on_agent_message_delta("Visible response".to_string());
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(!popup.contains("Stop this attempt and retry?"));
assert!(!popup.contains(SAFETY_BUFFERING_HEADER_TEXT));
for (show_buffering_ui, faster_model) in [
(true, Some("faster-model")),
(true, None),
(false, None),
(true, Some("faster-model")),
] {
let mut notification = safety_buffering_notification(thread_id, turn_id, faster_model);
notification.show_buffering_ui = show_buffering_ui;
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(notification),
/*replay_kind*/ None,
);
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), popup);
}
handle_turn_completed(&mut chat, turn_id, /*duration_ms*/ None);
@@ -256,7 +375,7 @@ async fn safety_buffering_without_retry_shows_short_app_message() {
#[tokio::test]
async fn safety_buffering_ignores_hidden_stale_and_historical_updates() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
@@ -289,6 +408,7 @@ async fn safety_buffering_ignores_hidden_stale_and_historical_updates() {
/*replay_kind*/ None,
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
hidden.show_buffering_ui = false;
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(hidden),
@@ -303,6 +423,7 @@ async fn safety_buffering_ignores_hidden_stale_and_historical_updates() {
None
);
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
}
#[tokio::test]

View File

@@ -21,7 +21,10 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() {
}),
/*replay_kind*/ None,
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("thinking a bit more"));
assert!(
render_bottom_popup(&chat, /*width*/ 80)
.contains("Giving this request a little extra thought")
);
chat.queue_user_message(UserMessage::from("queued follow-up"));
chat.bottom_pane
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());