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 22:49:48 +00:00
committed by copyberry
parent 69cebb5d15
commit 0588fc941c
12 changed files with 287 additions and 34 deletions
+10
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,
-4
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;
}
@@ -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,
+9
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,
+94 -18
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()
});
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+127 -6
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]
@@ -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());