mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
TUI support for buffer experience (#29919)
This commit is contained in:
committed by
GitHub
Unverified
parent
3e51b46eba
commit
6801941cfe
@@ -337,6 +337,79 @@ impl App {
|
||||
self.chat_widget.prepare_local_op_submission(&op);
|
||||
self.submit_active_thread_op(app_server, op).await?;
|
||||
}
|
||||
AppEvent::RetrySafetyBufferedTurn {
|
||||
thread_id,
|
||||
turn_id,
|
||||
model,
|
||||
mut turn,
|
||||
} => {
|
||||
if self.active_thread_id != Some(thread_id)
|
||||
|| self.chat_widget.thread_id() != Some(thread_id)
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
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 Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
let AppCommand::UserTurn {
|
||||
model: turn_model,
|
||||
effort,
|
||||
collaboration_mode,
|
||||
..
|
||||
} = &mut turn
|
||||
else {
|
||||
self.chat_widget.add_error_message(
|
||||
"Failed to retry with a faster model: original turn is unavailable."
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
*turn_model = model.clone();
|
||||
*effort = Some(ReasoningEffortConfig::Low);
|
||||
*collaboration_mode = collaboration_mode.as_ref().map(|mode| {
|
||||
mode.with_updates(
|
||||
Some(model),
|
||||
Some(Some(ReasoningEffortConfig::Low)),
|
||||
/*developer_instructions*/ None,
|
||||
)
|
||||
});
|
||||
|
||||
if let Err(err) = app_server.turn_interrupt(thread_id, turn_id).await {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to retry with a faster model: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let rollback_response =
|
||||
match app_server.thread_rollback(thread_id, /*num_turns*/ 1).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to retry with a faster model: {err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
};
|
||||
|
||||
self.chat_widget.prepare_safety_buffering_retry();
|
||||
self.handle_thread_rollback_response_with_origin(
|
||||
thread_id,
|
||||
/*num_turns*/ 1,
|
||||
&rollback_response,
|
||||
super::thread_routing::ThreadRollbackOrigin::SafetyBufferingRetry,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(err) = self.submit_thread_op(app_server, thread_id, turn).await {
|
||||
self.chat_widget.fail_safety_buffering_retry();
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to retry with a faster model: {err}"));
|
||||
}
|
||||
}
|
||||
AppEvent::RestoreCancelledTurn(prompt) => {
|
||||
self.apply_cancelled_turn_edit(prompt);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
use super::*;
|
||||
use crate::session_resume::read_session_model;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum ThreadRollbackOrigin {
|
||||
Backtrack,
|
||||
SafetyBufferingRetry,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
@@ -643,7 +649,7 @@ impl App {
|
||||
.as_ref()
|
||||
.map(|profile| &profile.permission_profile),
|
||||
);
|
||||
app_server
|
||||
let response = app_server
|
||||
.turn_start(
|
||||
thread_id,
|
||||
items.to_vec(),
|
||||
@@ -661,6 +667,12 @@ impl App {
|
||||
final_output_json_schema.clone(),
|
||||
)
|
||||
.await?;
|
||||
if self.active_thread_id == Some(thread_id)
|
||||
&& self.chat_widget.thread_id() == Some(thread_id)
|
||||
{
|
||||
self.chat_widget
|
||||
.record_safety_buffering_turn(response.turn.id, op);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1395,6 +1407,22 @@ impl App {
|
||||
thread_id: ThreadId,
|
||||
num_turns: u32,
|
||||
response: &ThreadRollbackResponse,
|
||||
) {
|
||||
self.handle_thread_rollback_response_with_origin(
|
||||
thread_id,
|
||||
num_turns,
|
||||
response,
|
||||
ThreadRollbackOrigin::Backtrack,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn handle_thread_rollback_response_with_origin(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
num_turns: u32,
|
||||
response: &ThreadRollbackResponse,
|
||||
origin: ThreadRollbackOrigin,
|
||||
) {
|
||||
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
|
||||
let mut store = channel.store.lock().await;
|
||||
@@ -1421,7 +1449,12 @@ impl App {
|
||||
self.clear_active_thread().await;
|
||||
}
|
||||
}
|
||||
self.handle_backtrack_rollback_succeeded(num_turns);
|
||||
match origin {
|
||||
ThreadRollbackOrigin::Backtrack => self.handle_backtrack_rollback_succeeded(num_turns),
|
||||
ThreadRollbackOrigin::SafetyBufferingRetry => {
|
||||
self.apply_non_pending_thread_rollback(num_turns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
|
||||
|
||||
@@ -161,6 +161,14 @@ pub(crate) enum AppEvent {
|
||||
op: AppCommand,
|
||||
},
|
||||
|
||||
/// Interrupt, roll back, and retry a safety-buffered turn with the server-selected model.
|
||||
RetrySafetyBufferedTurn {
|
||||
thread_id: ThreadId,
|
||||
turn_id: String,
|
||||
model: String,
|
||||
turn: AppCommand,
|
||||
},
|
||||
|
||||
/// Deliver a synthetic history lookup response to a specific thread channel.
|
||||
ThreadHistoryEntryResponse {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -395,10 +395,12 @@ mod review_popups;
|
||||
use self::review::ReviewState;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::review_popups::show_review_commit_picker_with_entries;
|
||||
mod safety_buffering;
|
||||
mod service_tiers;
|
||||
mod settings;
|
||||
mod settings_popups;
|
||||
mod side;
|
||||
use self::safety_buffering::SafetyBufferingState;
|
||||
mod status_state;
|
||||
mod windows_sandbox_prompts;
|
||||
use self::status_state::StatusIndicatorState;
|
||||
@@ -584,6 +586,7 @@ pub(crate) struct ChatWidget {
|
||||
last_unified_wait: Option<UnifiedExecWaitState>,
|
||||
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
|
||||
turn_lifecycle: TurnLifecycleState,
|
||||
safety_buffering: SafetyBufferingState,
|
||||
task_complete_pending: bool,
|
||||
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
|
||||
/// Tracks per-server MCP startup state while startup is in progress.
|
||||
|
||||
@@ -154,6 +154,7 @@ impl ChatWidget {
|
||||
last_unified_wait: None,
|
||||
unified_exec_wait_streak: None,
|
||||
turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep),
|
||||
safety_buffering: SafetyBufferingState::default(),
|
||||
task_complete_pending: false,
|
||||
unified_exec_processes: Vec::new(),
|
||||
mcp_startup_status: None,
|
||||
|
||||
@@ -147,6 +147,9 @@ impl ChatWidget {
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
self.on_app_server_model_verification(¬ification.verifications)
|
||||
}
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification) => {
|
||||
self.on_model_safety_buffering_updated(notification, replay_kind)
|
||||
}
|
||||
ServerNotification::Warning(notification) => self.on_warning(notification.message),
|
||||
ServerNotification::GuardianWarning(notification) => {
|
||||
self.on_warning(notification.message)
|
||||
@@ -208,7 +211,6 @@ impl ChatWidget {
|
||||
| ServerNotification::ExternalAgentConfigImportProgress(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::ModelSafetyBufferingUpdated(_)
|
||||
| ServerNotification::TurnModerationMetadata(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
|
||||
@@ -24,6 +24,7 @@ impl ChatWidget {
|
||||
duration_ms,
|
||||
} = turn;
|
||||
if matches!(status, TurnStatus::InProgress) {
|
||||
self.turn_lifecycle.last_turn_id = Some(turn_id.clone());
|
||||
self.last_non_retry_error = None;
|
||||
self.on_task_started();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Safety-buffering status and retry UI for active turns.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
|
||||
|
||||
const SAFETY_BUFFERING_PROMPT_VIEW_ID: &str = "safety-buffering-prompt";
|
||||
|
||||
const SAFETY_BUFFERING_MESSAGE_WITH_RETRY: &str = "This request requires additional safety checks, which can take extra time. You can retry with a faster model for a quicker response, though it may be less capable of handling complex requests.";
|
||||
const SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY: &str =
|
||||
"This request requires additional safety checks, which can take extra time.";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ActiveSafetyBuffering {
|
||||
turn_id: String,
|
||||
retry_prompt_shown: bool,
|
||||
agent_message_started: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct SafetyBufferingState {
|
||||
submitted_turn: Option<(String, AppCommand)>,
|
||||
active: Option<ActiveSafetyBuffering>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn record_safety_buffering_turn(&mut self, turn_id: String, turn: &AppCommand) {
|
||||
self.safety_buffering.submitted_turn = Some((turn_id, turn.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn reset_safety_buffering_for_turn_start(&mut self) {
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
|
||||
self.safety_buffering.active = None;
|
||||
}
|
||||
|
||||
pub(crate) fn clear_safety_buffering(&mut self) {
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
|
||||
self.safety_buffering = SafetyBufferingState::default();
|
||||
}
|
||||
|
||||
pub(super) fn mark_safety_buffering_agent_message_started(&mut self) {
|
||||
if let Some(active) = self.safety_buffering.active.as_mut() {
|
||||
active.agent_message_started = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn safety_buffering_is_waiting(&self) -> bool {
|
||||
self.safety_buffering
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| !active.agent_message_started)
|
||||
}
|
||||
|
||||
pub(crate) fn can_retry_safety_buffered_turn(&self, turn_id: &str) -> bool {
|
||||
self.turn_lifecycle.agent_turn_running
|
||||
&& self
|
||||
.safety_buffering
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.turn_id == turn_id && !active.agent_message_started)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_safety_buffering_retry(&mut self) {
|
||||
let cancel_edit = std::mem::take(&mut self.cancel_edit);
|
||||
self.last_rendered_user_message_display = None;
|
||||
self.finalize_turn();
|
||||
self.cancel_edit = cancel_edit;
|
||||
self.input_queue.user_turn_pending_start = true;
|
||||
}
|
||||
|
||||
pub(crate) fn fail_safety_buffering_retry(&mut self) {
|
||||
self.input_queue.user_turn_pending_start = false;
|
||||
self.clear_safety_buffering();
|
||||
let prompt = self.cancel_edit.prompt.take();
|
||||
self.clear_cancel_edit();
|
||||
if let Some(prompt) = prompt {
|
||||
self.restore_user_message_to_composer(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_model_safety_buffering_updated(
|
||||
&mut self,
|
||||
notification: ModelSafetyBufferingUpdatedNotification,
|
||||
replay_kind: Option<ReplayKind>,
|
||||
) {
|
||||
let ModelSafetyBufferingUpdatedNotification {
|
||||
turn_id,
|
||||
show_buffering_ui,
|
||||
faster_model,
|
||||
..
|
||||
} = notification;
|
||||
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())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !show_buffering_ui {
|
||||
if self
|
||||
.safety_buffering
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.turn_id == turn_id)
|
||||
{
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
|
||||
self.safety_buffering.active = None;
|
||||
self.restore_reasoning_status_header();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let retry_turn = self
|
||||
.safety_buffering
|
||||
.submitted_turn
|
||||
.as_ref()
|
||||
.filter(|(submitted_turn_id, _)| replay_kind.is_none() && submitted_turn_id == &turn_id)
|
||||
.map(|(_, turn)| turn.clone());
|
||||
let thread_id = self.thread_id;
|
||||
let can_offer_retry = faster_model.is_some() && retry_turn.is_some() && thread_id.is_some();
|
||||
if !can_offer_retry {
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID);
|
||||
}
|
||||
let previous_active = self
|
||||
.safety_buffering
|
||||
.active
|
||||
.as_ref()
|
||||
.filter(|active| active.turn_id == turn_id);
|
||||
let retry_prompt_shown = previous_active.is_some_and(|active| active.retry_prompt_shown);
|
||||
let should_show_retry_prompt = can_offer_retry && !retry_prompt_shown;
|
||||
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(),
|
||||
retry_prompt_shown: retry_prompt_shown || should_show_retry_prompt,
|
||||
agent_message_started,
|
||||
});
|
||||
|
||||
let message = if can_offer_retry {
|
||||
SAFETY_BUFFERING_MESSAGE_WITH_RETRY
|
||||
} else {
|
||||
SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY
|
||||
};
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.set_status(
|
||||
"Working".to_string(),
|
||||
Some(message.to_string()),
|
||||
StatusDetailsCapitalization::Preserve,
|
||||
/*details_max_lines*/ 6,
|
||||
);
|
||||
|
||||
let (Some(faster_model), Some(turn), Some(thread_id)) =
|
||||
(faster_model, retry_turn, thread_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !should_show_retry_prompt {
|
||||
return;
|
||||
}
|
||||
|
||||
let header = ColumnRenderable::with(vec![
|
||||
Box::new(Line::from("Additional safety checks").bold()) as Box<dyn Renderable>,
|
||||
Box::new(
|
||||
Paragraph::new(Line::from(SAFETY_BUFFERING_MESSAGE_WITH_RETRY).dim())
|
||||
.wrap(Wrap { trim: false }),
|
||||
),
|
||||
]);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Retry with a faster model".to_string(),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::RetrySafetyBufferedTurn {
|
||||
thread_id,
|
||||
turn_id: turn_id.clone(),
|
||||
model: faster_model.clone(),
|
||||
turn: turn.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Keep waiting".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ impl ChatWidget {
|
||||
}
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.turn_lifecycle.reset_thread();
|
||||
self.clear_safety_buffering();
|
||||
self.thread_name = session.thread_name.clone();
|
||||
self.current_goal_status_indicator = None;
|
||||
self.current_goal_status = None;
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/app_server.rs
|
||||
expression: popup
|
||||
---
|
||||
Additional safety checks
|
||||
This request requires additional safety checks, which can take extra time.
|
||||
You can retry with a faster model for a quicker response, though it may be
|
||||
less capable of handling complex requests.
|
||||
|
||||
› 1. Retry with a faster model
|
||||
2. Keep waiting
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/app_server.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
└ This request requires additional safety checks, which can take extra time.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.5 default · /tmp/project
|
||||
@@ -203,6 +203,11 @@ impl ChatWidget {
|
||||
// (between **/**) as the chunk header. Show this header as status.
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
|
||||
if self.safety_buffering_is_waiting() {
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.unified_exec_wait_streak.is_some() {
|
||||
// Unified exec waiting should take precedence over reasoning-derived status headers.
|
||||
self.request_redraw();
|
||||
@@ -383,6 +388,7 @@ impl ChatWidget {
|
||||
pub(super) fn handle_streaming_delta(&mut self, delta: String) {
|
||||
if !delta.is_empty() {
|
||||
self.record_visible_turn_activity();
|
||||
self.mark_safety_buffering_agent_message_started();
|
||||
}
|
||||
if self.stream_controller.is_none() {
|
||||
// Before starting an agent stream, flush any active exec cell group.
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(super) use codex_app_server_protocol::MarketplaceUpgradeResponse;
|
||||
pub(super) use codex_app_server_protocol::McpServerStartupState;
|
||||
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
|
||||
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
pub(super) use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
|
||||
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
|
||||
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
|
||||
pub(super) use codex_app_server_protocol::NonSteerableTurnKind;
|
||||
|
||||
@@ -61,6 +61,194 @@ fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::Threa
|
||||
}
|
||||
}
|
||||
|
||||
fn start_safety_buffering_test_turn(
|
||||
chat: &mut ChatWidget,
|
||||
op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||||
) -> (ThreadId, &'static str, Op) {
|
||||
let thread_id = ThreadId::new();
|
||||
let turn_id = "turn-safety-buffering";
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.submit_user_message(UserMessage::from("Explain the request"));
|
||||
let turn = next_submit_op(op_rx);
|
||||
assert_matches!(&turn, Op::UserTurn { .. });
|
||||
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: turn_id.to_string(),
|
||||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: Some(0),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
(thread_id, turn_id, turn)
|
||||
}
|
||||
|
||||
fn safety_buffering_notification(
|
||||
thread_id: ThreadId,
|
||||
turn_id: &str,
|
||||
faster_model: Option<&str>,
|
||||
) -> ModelSafetyBufferingUpdatedNotification {
|
||||
ModelSafetyBufferingUpdatedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
model: "current-model".to_string(),
|
||||
use_cases: Vec::new(),
|
||||
reasons: Vec::new(),
|
||||
show_buffering_ui: true,
|
||||
faster_model: faster_model.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 (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"));
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("safety_buffering_retry_prompt", popup);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let (event_thread_id, event_turn_id, model, turn) = loop {
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::RetrySafetyBufferedTurn {
|
||||
thread_id,
|
||||
turn_id,
|
||||
model,
|
||||
turn,
|
||||
}) => break (thread_id, turn_id, model, turn),
|
||||
Ok(_) => continue,
|
||||
Err(err) => panic!("expected safety-buffering retry event: {err}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(event_thread_id, thread_id);
|
||||
assert_eq!(event_turn_id, turn_id);
|
||||
assert_eq!(model, "faster-model");
|
||||
assert_matches!(turn, Op::UserTurn { .. });
|
||||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn safety_buffering_stops_retrying_after_agent_message_starts() {
|
||||
let (mut chat, _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,
|
||||
);
|
||||
assert!(chat.can_retry_safety_buffered_turn(turn_id));
|
||||
|
||||
chat.on_agent_message_delta("Visible response".to_string());
|
||||
|
||||
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
|
||||
}
|
||||
|
||||
#[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;
|
||||
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, /*faster_model*/ None,
|
||||
)),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let render_popup = |chat: &ChatWidget| {
|
||||
normalize_snapshot_paths(render_bottom_popup(chat, /*width*/ 80))
|
||||
};
|
||||
let popup = render_popup(&chat);
|
||||
assert_chatwidget_snapshot!("safety_buffering_status_without_retry", popup,);
|
||||
|
||||
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||||
chat.record_safety_buffering_turn("other-turn".to_string(), &turn);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert_eq!(render_popup(&chat), popup);
|
||||
|
||||
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(notification),
|
||||
Some(ReplayKind::ThreadSnapshot),
|
||||
);
|
||||
assert_eq!(render_popup(&chat), popup);
|
||||
}
|
||||
|
||||
#[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 (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"));
|
||||
hidden.show_buffering_ui = false;
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(hidden),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||||
thread_id,
|
||||
"stale-turn",
|
||||
Some("faster-model"),
|
||||
)),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||||
thread_id,
|
||||
turn_id,
|
||||
Some("faster-model"),
|
||||
)),
|
||||
Some(ReplayKind::ResumeInitialMessages),
|
||||
);
|
||||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
|
||||
|
||||
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(hidden.clone()),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
|
||||
hidden.show_buffering_ui = false;
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelSafetyBufferingUpdated(hidden),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
chat.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible")
|
||||
.details(),
|
||||
None
|
||||
);
|
||||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_url_elicitation_is_declined() {
|
||||
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
@@ -969,6 +1157,24 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_safety_access_errors_render_dedicated_notice() {
|
||||
let message = "Invalid prompt: we've limited access to this content for safety reasons.";
|
||||
for message in [
|
||||
message.to_string(),
|
||||
json!({ "error": { "message": message } }).to_string(),
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_non_retry_error(message, /*codex_error_info*/ None);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(rendered.contains("This content can't be shown"));
|
||||
assert!(!rendered.contains("Invalid prompt:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_app_server_model_verification_renders_warning() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
const SAFETY_ACCESS_BLOCK_PREFIX: &str =
|
||||
"Invalid prompt: we've limited access to this content for safety reasons.";
|
||||
|
||||
impl ChatWidget {
|
||||
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
|
||||
///
|
||||
@@ -48,6 +51,7 @@ impl ChatWidget {
|
||||
|
||||
pub(super) fn on_task_started(&mut self) {
|
||||
self.input_queue.user_turn_pending_start = false;
|
||||
self.reset_safety_buffering_for_turn_start();
|
||||
self.turn_lifecycle.start(Instant::now());
|
||||
self.transcript.reset_turn_flags();
|
||||
self.adaptive_chunking.reset();
|
||||
@@ -297,6 +301,7 @@ impl ChatWidget {
|
||||
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
|
||||
/// and should continue to drive the bottom-pane running indicator while it is in progress.
|
||||
pub(super) fn finalize_turn(&mut self) {
|
||||
self.clear_safety_buffering();
|
||||
// Drop preview-only stream tail content on any termination path before
|
||||
// failed-cell finalization, so transient tail cells are never persisted.
|
||||
self.clear_active_stream_tail();
|
||||
@@ -424,6 +429,18 @@ impl ChatWidget {
|
||||
.is_some_and(is_app_server_cyber_policy_error)
|
||||
{
|
||||
self.on_cyber_policy_error();
|
||||
} else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)
|
||||
|| serde_json::from_str::<serde_json::Value>(&message).is_ok_and(|response| {
|
||||
response["error"]["message"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX))
|
||||
})
|
||||
{
|
||||
self.input_queue.submit_pending_steers_after_interrupt = false;
|
||||
self.finalize_turn();
|
||||
self.add_to_history(history_cell::new_safety_access_block_event());
|
||||
self.request_redraw();
|
||||
self.maybe_send_next_queued_input();
|
||||
} else if let Some(info) = codex_error_info
|
||||
.as_ref()
|
||||
.and_then(app_server_rate_limit_error_kind)
|
||||
|
||||
@@ -85,6 +85,37 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SafetyAccessBlockCell;
|
||||
|
||||
const SAFETY_ACCESS_BLOCK_TITLE: &str = "This content can't be shown";
|
||||
const SAFETY_ACCESS_BLOCK_BODY: &str = "We've limited access to this content for safety reasons. This type of information may be used to benefit or to harm people.";
|
||||
|
||||
pub(crate) fn new_safety_access_block_event() -> SafetyAccessBlockCell {
|
||||
SafetyAccessBlockCell
|
||||
}
|
||||
|
||||
impl HistoryCell for SafetyAccessBlockCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![vec!["ⓘ ".cyan(), SAFETY_ACCESS_BLOCK_TITLE.bold()].into()];
|
||||
let body = Line::from(vec![" ".into(), SAFETY_ACCESS_BLOCK_BODY.dim()]);
|
||||
let wrap_width = width.saturating_sub(2).max(1) as usize;
|
||||
let wrapped = adaptive_wrap_line(
|
||||
&body,
|
||||
RtOptions::new(wrap_width).subsequent_indent(" ".into()),
|
||||
);
|
||||
push_owned_lines(&wrapped, &mut lines);
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![
|
||||
Line::from(SAFETY_ACCESS_BLOCK_TITLE),
|
||||
Line::from(SAFETY_ACCESS_BLOCK_BODY),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber";
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
ⓘ This content can't be shown
|
||||
We've limited access to this content for safety reasons. This type of
|
||||
information may be used to benefit or to harm people.
|
||||
@@ -699,6 +699,13 @@ fn cyber_policy_error_event_snapshot() {
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_access_block_event_snapshot() {
|
||||
let cell = new_safety_access_block_event();
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyber_policy_error_event_narrow_snapshot() {
|
||||
let cell = new_cyber_policy_error_event();
|
||||
|
||||
Reference in New Issue
Block a user