Allow reviewing and continuing misalignment-paused chats (#42410)

## Why

Misalignment policy failures pause the current chat. When the server provides findings and a continuation request, users should be able to inspect them and explicitly decide whether to proceed.

## What changed

- Add a `Review findings` flow that shows the detailed explanation and quoted continuation request in an overlay.
- Require explicit acknowledgement before starting a continuation turn, while preserving the active approval and sandbox settings.
- Keep ordinary input blocked and reject stale, replaced, invalid, or duplicate continuation attempts.
- Label the overlay shortcut as `q close` to match its behavior.

## Testing

- Add TUI tests covering findings review, explicit confirmation, successful and failed continuation, stale findings, duplicate events, and invalid continuation data.

GitOrigin-RevId: 36a738adf272f95db92eaee5712fb00061d0fa52
This commit is contained in:
Eric Traut
2026-09-03 01:51:14 +00:00
committed by copyberry
parent 93053c7f5d
commit fdf23b4097
31 changed files with 740 additions and 41 deletions
+1
View File
@@ -223,6 +223,7 @@ mod history_pagination;
mod history_ui;
mod input;
mod loaded_threads;
mod misalignment_policy;
mod pending_interactive_replay;
mod permission_shortcuts;
mod pets;
+7
View File
@@ -62,6 +62,13 @@ impl App {
}
match event {
AppEvent::ReviewMisalignment(review) => {
self.open_misalignment_review(tui, review);
}
AppEvent::ContinueMisalignment(review) => {
self.continue_misalignment(app_server, review).await;
}
AppEvent::CloseMisalignmentReview => self.chat_widget.show_misalignment_policy_precaution(),
AppEvent::SkillsListLoaded { ref cwd, .. }
| AppEvent::PluginMentionsLoaded { ref cwd, .. }
if cwds_differ(cwd, self.config.cwd.as_path()) => {}
+183
View File
@@ -0,0 +1,183 @@
//! Continue only the displayed, acknowledged misalignment failure. Submission stays outside
//! the ordinary input queue, which must remain blocked until the server accepts the new turn.
use super::*;
use crate::app_server_session::turn_permissions_overrides;
use crate::chatwidget::MisalignmentReview;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
impl App {
pub(super) fn open_misalignment_review(
&mut self,
tui: &mut tui::Tui,
review: Arc<MisalignmentReview>,
) {
if !self.chat_widget.is_current_misalignment_review(&review) {
return;
}
self.chat_widget
.show_misalignment_review_confirmation(Arc::clone(&review));
let mut lines = Vec::new();
if let Some(message) = review.continuation_message() {
lines.push(Line::from("Continuation request (quoted)").bold());
// Keep the submitted text ahead of potentially long findings, without interpreting it.
lines.push(Line::from(format!("{message:?}")));
lines.push(Line::default());
}
crate::markdown::append_markdown(
review
.details
.detailed_explanation
.as_deref()
.unwrap_or_default(),
/*width*/ None,
Some(self.chat_widget.config_ref().cwd.as_path()),
&mut lines,
);
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(
lines,
"What we detected".to_string(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
}
pub(super) async fn continue_misalignment(
&mut self,
app_server: &mut AppServerSession,
review: Arc<MisalignmentReview>,
) {
if self.chat_widget.is_current_misalignment_review(&review) {
self.chat_widget.show_misalignment_policy_precaution();
}
// The event store can be newer than the widget while UI actions are queued.
if let Some(channel) = self.thread_event_channels.get(&review.thread_id) {
let store = channel.store.lock().await;
if store.active_turn_id().is_some() {
return;
}
let latest = store.buffer.iter().rev().find_map(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return None;
};
let (turn_id, error) = match notification.as_ref() {
ServerNotification::TurnStarted(_) => return Some(false),
ServerNotification::TurnCompleted(n) if n.turn.status == TurnStatus::Failed => {
(&n.turn.id, n.turn.error.as_ref())
}
ServerNotification::TurnCompleted(_) => return Some(false),
ServerNotification::Error(n)
if !n.will_retry
&& (n.turn_id == review.turn_id
|| n.error.codex_error_info
== Some(
AppServerCodexErrorInfo::MisalignmentPolicyViolation,
)) =>
{
(&n.turn_id, Some(&n.error))
}
_ => return None,
};
let Some(error) = error else {
return Some(false);
};
if turn_id != &review.turn_id
|| error.codex_error_info
!= Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation)
{
return Some(false);
}
// Empty duplicates do not supersede the latest supplied findings.
error
.misalignment
.as_ref()
.map(|details| details == &review.details)
});
if latest == Some(false) {
return;
}
}
if self.current_displayed_thread_id() != Some(review.thread_id)
|| self
.active_thread_rx
.as_ref()
.is_some_and(|rx| !rx.is_empty())
|| !self.chat_widget.is_current_misalignment_review(&review)
{
return;
}
let Some(message) = review.continuation_message() else {
return;
};
let config = self.chat_widget.config_ref();
let permissions_override = Self::turn_permissions_override_from_config(
config,
config.permissions.active_permission_profile().as_ref(),
self.runtime_permission_profile_override
.as_ref()
.and_then(RuntimePermissionProfileOverride::turn_permission_profile),
);
let Ok((sandbox_policy, permissions)) =
turn_permissions_overrides(permissions_override, config.cwd.as_path())
else {
self.chat_widget.add_error_message(
"Couldn’t continue this chat. Review its latest status before trying again."
.to_string(),
);
return;
};
let result = app_server
.request_handle()
.request_typed::<TurnStartResponse>(ClientRequest::TurnStart {
request_id: app_server.next_request_id(),
params: TurnStartParams {
thread_id: review.thread_id.to_string(),
cwd: Some(config.cwd.to_path_buf()),
runtime_workspace_roots: Some(
config.permissions.user_visible_workspace_roots().to_vec(),
),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: Some(config.approvals_reviewer.into()),
sandbox_policy,
permissions,
input: vec![UserInput::Text {
text: message.to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: Some(HashMap::from([(
"misalignment_override".to_string(),
serde_json::json!({"timestamp": chrono::Utc::now().timestamp_millis()})
.to_string(),
)])),
..Default::default()
},
})
.await;
match result {
Ok(response) => {
let store = &self.ensure_thread_channel(review.thread_id).store;
store.lock().await.active_turn_id = Some(response.turn.id.clone());
self.chat_widget.handle_server_notification(
ServerNotification::TurnStarted(
codex_app_server_protocol::TurnStartedNotification {
thread_id: review.thread_id.to_string(),
turn: response.turn,
},
),
/*replay_kind*/ None,
);
}
Err(_) => {
// An RPC diagnostic can contain the submitted steer. Keep the review error generic.
self.chat_widget.add_error_message(
"Couldn’t continue this chat. Review its latest status before trying again."
.to_string(),
);
}
}
}
}
+2
View File
@@ -24,6 +24,8 @@ mod key_chords;
mod luna_reserve_recovery_tests;
#[path = "tests/mcp_startup.rs"]
mod mcp_startup;
#[path = "tests/misalignment_policy_tests.rs"]
mod misalignment_policy;
mod model_catalog;
#[path = "tests/patch_approval_tests.rs"]
mod patch_approval_tests;
@@ -0,0 +1,209 @@
use super::session_lifecycle_requests::recorded_params;
use super::session_lifecycle_requests::start_recording_app_server;
use super::*;
use codex_app_server_protocol::MisalignmentErrorDetails;
use codex_app_server_protocol::MisalignmentSteer;
use pretty_assertions::assert_eq;
fn policy_error() -> AppServerTurnError {
AppServerTurnError {
message: "Chat paused".into(),
codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation),
additional_details: None,
misalignment: Some(MisalignmentErrorDetails {
error_type: None,
detailed_explanation: Some(
"The proposed action exceeded the request.\n\n".repeat(1_500),
),
steer: Some(MisalignmentSteer {
message: "Continue **only** within the requested scope.\nDo not edit files.".into(),
}),
}),
}
}
fn error_notification(
thread_id: ThreadId,
turn_id: &str,
error: AppServerTurnError,
) -> ServerNotification {
ServerNotification::Error(codex_app_server_protocol::ErrorNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.into(),
error,
will_retry: false,
})
}
#[tokio::test]
async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> {
for reject in [false, true] {
let (mut app, mut rx, _) = make_test_app_with_channels().await;
let (mut server, requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let mut session = if reject {
test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf())
} else {
server.start_thread(&app.config).await?.session
};
let thread_id = session.thread_id;
// Resend local restrictions restored after reconnect, rather than inheriting server settings.
session.approval_policy = AskForApproval::OnRequest;
session.permission_profile = PermissionProfile::read_only();
session.active_permission_profile = None;
app.active_thread_id = Some(thread_id);
app.chat_widget.handle_thread_session(session);
app.runtime_permission_profile_override = Some(
RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()),
);
app.chat_widget.handle_server_notification(
error_notification(thread_id, "failed-turn", policy_error()),
/*replay_kind*/ None,
);
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
let review = std::iter::from_fn(|| rx.try_recv().ok())
.find_map(|event| match event {
AppEvent::ReviewMisalignment(review) => Some(review),
_ => None,
})
.expect("review action");
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_event(
&mut tui,
&mut server,
AppEvent::ReviewMisalignment(Arc::clone(&review)),
)
.await?;
let Some(Overlay::Static(overlay)) = app.overlay.as_mut() else {
panic!("findings overlay")
};
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
/*width*/ 38, /*height*/ 16,
))?;
terminal.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))?;
insta::assert_snapshot!("misalignment_findings_38", terminal.backend());
app.overlay = None;
assert!(recorded_params(&requests, "turn/start").is_empty());
let mut withdrawn = policy_error();
withdrawn
.misalignment
.as_mut()
.unwrap()
.detailed_explanation = None;
// Reject both a newer turn and withdrawn findings followed by an empty duplicate.
for notifications in [
vec![turn_completed_notification(
thread_id,
"newer-turn",
TurnStatus::Completed,
)],
vec![
error_notification(thread_id, &review.turn_id, withdrawn.clone()),
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: Turn {
error: Some(AppServerTurnError {
misalignment: None,
..withdrawn
}),
..test_turn(&review.turn_id, TurnStatus::Failed, Vec::new())
},
}),
],
] {
for notification in notifications {
app.enqueue_thread_notification(thread_id, notification)
.await?;
}
app.continue_misalignment(&mut server, Arc::clone(&review))
.await;
assert!(recorded_params(&requests, "turn/start").is_empty());
app.thread_event_channels.remove(&thread_id);
}
if reject {
app.continue_misalignment(&mut server, Arc::clone(&review))
.await;
assert!(app.chat_widget.has_misalignment_policy_violation());
let errors = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 80)))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(
"misalignment_continue_failed",
format!(
"{errors}\n{}",
render_bottom_popup(&app.chat_widget, /*width*/ 80)
)
);
server.shutdown().await?;
proxy.await??;
continue;
}
// A settings error is not a new turn and must not silently disable Continue.
app.enqueue_thread_notification(
thread_id,
error_notification(
thread_id,
"settings-update",
AppServerTurnError {
codex_error_info: Some(AppServerCodexErrorInfo::BadRequest),
..policy_error()
},
),
)
.await?;
app.continue_misalignment(&mut server, Arc::clone(&review))
.await;
app.continue_misalignment(&mut server, Arc::clone(&review))
.await;
assert!(!app.chat_widget.has_misalignment_policy_violation());
let params = recorded_params(&requests, "turn/start");
let [params] = params.as_slice() else {
panic!("expected one turn/start: {params:?}")
};
let mut params: codex_app_server_protocol::TurnStartParams =
serde_json::from_value(params.clone())?;
let metadata = params.responsesapi_client_metadata.take().unwrap();
let override_value: serde_json::Value =
serde_json::from_str(&metadata["misalignment_override"])?;
let timestamp = override_value["timestamp"].as_u64().unwrap();
assert_eq!(metadata.len(), 1);
assert_eq!(override_value, serde_json::json!({"timestamp": timestamp}));
let config = app.chat_widget.config_ref();
assert_eq!(
params,
codex_app_server_protocol::TurnStartParams {
thread_id: thread_id.to_string(),
cwd: Some(config.cwd.to_path_buf()),
runtime_workspace_roots: Some(
config.permissions.user_visible_workspace_roots().to_vec()
),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(config.approvals_reviewer.into()),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false
}),
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Continue **only** within the requested scope.\nDo not edit files."
.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}
);
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
@@ -0,0 +1,12 @@
---
source: tui/src/app/tests/misalignment_policy_tests.rs
expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80))"
---
■ Couldn’t continue this chat. Review its latest status before trying again.
Chat paused as a precaution
We couldn’t confirm the agent was interpreting your instructions correctly.
Review what we detected before deciding to continue.
› 1. Review findings
2. New chat
3. Resume another chat
@@ -0,0 +1,21 @@
---
source: tui/src/app/tests/misalignment_policy_tests.rs
assertion_line: 75
expression: terminal.backend()
---
"/ What we detected/ / / / / / / / / / "
"Continuation request (quoted) "
""Continue **only** within the "
"requested scope.\nDo not edit files." "
" "
"The proposed action exceeded the "
"request. "
" "
"The proposed action exceeded the "
"request. "
" "
"The proposed action exceeded the "
"───────────────────────────────── 0% ─"
" ↑/↓ to scroll pgup/pgdn to page h"
" q close "
" "
@@ -12,4 +12,4 @@ expression: text
───────────────────────────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
+3
View File
@@ -224,6 +224,9 @@ pub(crate) struct AgentsOverviewThreadRefresh {
#[allow(clippy::large_enum_variant)]
#[derive(Debug, IntoStaticStr)]
pub(crate) enum AppEvent {
ReviewMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
ContinueMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
CloseMisalignmentReview,
/// Open the daemon-wide overview of recent and locally retained root sessions.
OpenAgentsOverview,
/// Update the daemon-wide overview after a background thread listing finishes.
+2 -1
View File
@@ -365,6 +365,7 @@ mod keymap_picker;
mod mcp_startup;
use self::mcp_startup::McpStartupStatus;
mod misalignment_policy;
pub(crate) use misalignment_policy::MisalignmentReview;
mod pets;
mod session_flow;
mod session_header;
@@ -691,7 +692,7 @@ pub(crate) struct ChatWidget {
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
misalignment_policy_violation: bool,
misalignment_policy_violation: Option<misalignment_policy::MisalignmentViolation>,
normal_placeholder_text: String,
side_placeholder_text: String,
forked_from: Option<ThreadId>,
+1 -1
View File
@@ -214,7 +214,7 @@ impl ChatWidget {
thread_rename_block_message: None,
active_side_conversation: false,
blocks_direct_input: false,
misalignment_policy_violation: false,
misalignment_policy_violation: None,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
forked_from: None,
+2 -2
View File
@@ -110,7 +110,7 @@ impl ChatWidget {
action: QueuedInputAction,
pending_pastes: Vec<(String, String)>,
) {
if self.misalignment_policy_violation {
if self.has_misalignment_policy_violation() {
return;
}
let should_run_now = self.is_session_configured()
@@ -140,7 +140,7 @@ impl ChatWidget {
/// If idle and there are queued inputs, submit exactly one to start the next turn.
pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool {
if !self.is_session_configured()
|| self.misalignment_policy_violation
|| self.has_misalignment_policy_violation()
|| self.input_queue.suppress_queue_autosend
|| self.input_queue.rate_limit_recovery_pending
|| self.input_queue.recovered_queue
@@ -113,7 +113,7 @@ impl ChatWidget {
history_record: UserMessageHistoryRecord,
shell_escape_policy: ShellEscapePolicy,
) -> (bool, Option<AppCommand>) {
if self.misalignment_policy_violation {
if self.has_misalignment_policy_violation() {
return (false, None);
}
if self.input_queue.rate_limit_recovery_pending {
@@ -1,17 +1,43 @@
//! Terminal precaution UI for misalignment policy violations.
//! Block ordinary input after a misalignment failure. Live findings can be reviewed and
//! explicitly acknowledged; review identity prevents stale UI actions from authorizing a turn.
use super::*;
use codex_app_server_protocol::MisalignmentErrorDetails;
const PRECAUTION_VIEW: &str = "misalignment_precaution";
#[derive(Debug)]
pub(crate) struct MisalignmentReview {
pub(crate) thread_id: ThreadId,
pub(crate) turn_id: String,
pub(crate) details: MisalignmentErrorDetails,
}
impl MisalignmentReview {
pub(crate) fn continuation_message(&self) -> Option<&str> {
self.details
.steer
.as_ref()
.map(|steer| steer.message.as_str())
.filter(|message| !message.trim().is_empty() && message.len() <= 1024)
}
}
pub(super) struct MisalignmentViolation {
turn_id: Option<String>,
review: Option<Arc<MisalignmentReview>>,
}
const MISALIGNMENT_POLICY_TITLE: &str = "Chat stopped as a precaution";
const MISALIGNMENT_POLICY_DESCRIPTION: &str = "We couldn’t confirm the agent was acting safely and following your instructions. To continue working, start or resume another chat.";
impl ChatWidget {
pub(crate) fn has_misalignment_policy_violation(&self) -> bool {
self.misalignment_policy_violation
self.misalignment_policy_violation.is_some()
}
pub(crate) fn rejects_misalignment_policy_op(&self, op: &AppCommand) -> bool {
self.misalignment_policy_violation
self.misalignment_policy_violation.is_some()
&& !matches!(
op,
AppCommand::Interrupt
@@ -24,11 +50,46 @@ impl ChatWidget {
}
pub(crate) fn on_misalignment_policy_violation(&mut self) {
if self.misalignment_policy_violation {
self.on_misalignment_error(/*turn_id*/ None, /*details*/ None);
}
pub(super) fn on_misalignment_error(
&mut self,
turn_id: Option<String>,
details: Option<MisalignmentErrorDetails>,
) {
if let (Some(turn_id), Some(latest)) = (&turn_id, &self.turn_lifecycle.last_turn_id)
&& turn_id != latest
{
return;
}
self.misalignment_policy_violation = true;
// Bound memory-only findings and newly submitted model input. Never truncate a steer.
let missing_details = details.is_none();
let details = details.filter(|details| {
details
.detailed_explanation
.as_ref()
.is_some_and(|text| !text.trim().is_empty() && text.len() <= 64 * 1024)
});
if let Some(current) = &self.misalignment_policy_violation
// A parent-thread precaution cannot become a continuable side-thread warning.
&& (current.turn_id.is_none()
|| (current.turn_id == turn_id
&& (missing_details
|| current.review.as_ref().map(|review| &review.details) == details.as_ref())))
{
return;
}
let review = self.thread_id.zip(turn_id.clone()).zip(details).map(
|((thread_id, turn_id), details)| {
Arc::new(MisalignmentReview {
thread_id,
turn_id,
details,
})
},
);
self.misalignment_policy_violation = Some(MisalignmentViolation { turn_id, review });
self.input_queue.clear();
self.finalize_turn();
self.refresh_pending_input_preview();
@@ -44,6 +105,10 @@ impl ChatWidget {
}
pub(crate) fn show_misalignment_policy_precaution(&mut self) {
if !self.has_misalignment_policy_violation() {
return;
}
self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW);
let mut items = vec![
SelectionItem {
name: "New chat".to_string(),
@@ -57,6 +122,23 @@ impl ChatWidget {
..Default::default()
},
];
let review = self
.misalignment_policy_violation
.as_ref()
.and_then(|violation| violation.review.clone());
if let Some(review) = &review {
let review = Arc::clone(review);
items.insert(
0,
SelectionItem {
name: "Review findings".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ReviewMisalignment(Arc::clone(&review)));
})],
..Default::default()
},
);
}
if self.remote_connection.is_some() {
items.insert(
1,
@@ -68,10 +150,13 @@ impl ChatWidget {
);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(PRECAUTION_VIEW),
header: Box::new(
Paragraph::new(vec![
Line::from(MISALIGNMENT_POLICY_TITLE).bold(),
Line::from(MISALIGNMENT_POLICY_DESCRIPTION).dim(),
Line::from(if review.is_some() { "Chat paused as a precaution" } else { MISALIGNMENT_POLICY_TITLE }).bold(),
Line::from(if review.is_some() {
"We couldn’t confirm the agent was interpreting your instructions correctly. Review what we detected before deciding to continue."
} else { MISALIGNMENT_POLICY_DESCRIPTION }).dim(),
])
.wrap(Wrap { trim: false }),
),
@@ -80,4 +165,62 @@ impl ChatWidget {
..Default::default()
});
}
pub(crate) fn is_current_misalignment_review(&self, review: &Arc<MisalignmentReview>) -> bool {
let current = self
.misalignment_policy_violation
.as_ref()
.and_then(|violation| violation.review.as_ref());
let turn_id = self.turn_lifecycle.last_turn_id.as_ref();
self.thread_id == Some(review.thread_id)
&& turn_id.is_none_or(|id| id == &review.turn_id)
&& current.is_some_and(|current| Arc::ptr_eq(current, review))
}
pub(crate) fn show_misalignment_review_confirmation(
&mut self,
review: Arc<MisalignmentReview>,
) {
if !self.is_current_misalignment_review(&review) {
return;
}
let can_continue = review.continuation_message().is_some();
self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(PRECAUTION_VIEW),
title: Some("Chat paused as a precaution".to_string()),
items: vec![
SelectionItem {
name: "Acknowledge findings and continue".to_string(),
is_disabled: !can_continue,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ContinueMisalignment(Arc::clone(&review)));
})],
require_explicit_confirmation: true,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Back".to_string(),
actions: vec![Box::new(|tx| tx.send(AppEvent::CloseMisalignmentReview))],
..Default::default()
},
],
allow_cancel: false,
..Default::default()
});
}
pub(crate) fn clear_misalignment_for_new_turn(&mut self, turn_id: &str) {
if self
.misalignment_policy_violation
.as_ref()
.is_some_and(|violation| violation.turn_id.as_ref().is_some_and(|id| id != turn_id))
{
self.misalignment_policy_violation = None;
self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW);
self.bottom_pane
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
}
}
}
+24 -5
View File
@@ -60,6 +60,9 @@ impl ChatWidget {
self.on_thread_settings_updated(notification);
}
ServerNotification::TurnStarted(notification) => {
if replay_kind.is_none() {
self.clear_misalignment_for_new_turn(&notification.turn.id);
}
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) {
@@ -136,10 +139,20 @@ impl ChatWidget {
notification.turn_id.clone(),
notification.error.message.clone(),
));
self.handle_non_retry_error(
notification.error.message,
notification.error.codex_error_info,
);
if !from_replay
&& notification.error.codex_error_info
== Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation)
{
self.on_misalignment_error(
Some(notification.turn_id),
notification.error.misalignment,
);
} else {
self.handle_non_retry_error(
notification.error.message,
notification.error.codex_error_info,
);
}
}
}
ServerNotification::SkillsChanged(_) => {
@@ -330,7 +343,13 @@ impl ChatWidget {
}
TurnStatus::Failed => {
if let Some(error) = notification.turn.error {
if self.last_non_retry_error.as_ref()
if replay_kind.is_none()
&& error.codex_error_info
== Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation)
{
self.on_misalignment_error(Some(notification.turn.id), error.misalignment);
self.last_non_retry_error = None;
} else if self.last_non_retry_error.as_ref()
== Some(&(notification.turn.id.clone(), error.message.clone()))
{
self.last_non_retry_error = None;
@@ -11,7 +11,7 @@ impl ChatWidget {
request: ServerRequest,
replay_kind: Option<ReplayKind>,
) {
if self.misalignment_policy_violation {
if self.has_misalignment_policy_violation() {
return;
}
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/misalignment_policy_tests.rs
assertion_line: 157
expression: "render_bottom_popup(&chat, 38)"
---
Chat paused as a precaution
› 1. Acknowledge findings and
continue
2. Back
@@ -123,3 +123,91 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() {
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAgentsOverview));
assert!(chat.bottom_pane.has_active_view());
}
fn review_details() -> codex_app_server_protocol::MisalignmentErrorDetails {
codex_app_server_protocol::MisalignmentErrorDetails {
error_type: Some("new_category".to_string()),
detailed_explanation: Some(
"The agent proposed a change outside your instructions.".to_string(),
),
steer: Some(codex_app_server_protocol::MisalignmentSteer {
message: "Continue with the requested change only.".to_string(),
}),
}
}
fn select_review(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> Arc<MisalignmentReview> {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
std::iter::from_fn(|| rx.try_recv().ok())
.find_map(|event| match event {
AppEvent::ReviewMisalignment(review) => Some(review),
_ => None,
})
.expect("review action")
}
#[tokio::test]
async fn misalignment_review_requires_current_findings() {
let (mut chat, mut rx, _) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details()));
let review = select_review(&mut chat, &mut rx);
chat.show_misalignment_review_confirmation(Arc::clone(&review));
assert_chatwidget_snapshot!(
"misalignment_review_narrow",
render_bottom_popup(&chat, /*width*/ 38)
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::ContinueMisalignment(current)) if Arc::ptr_eq(&current, &review))
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(rx.try_recv().is_err());
// A duplicate terminal event must not reset an in-progress review.
chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details()));
chat.on_misalignment_error(Some("turn-1".to_string()), /*details*/ None);
assert!(chat.is_current_misalignment_review(&review));
let mut changed = review_details();
changed.detailed_explanation = Some("Updated findings".to_string());
chat.on_misalignment_error(Some("turn-1".to_string()), Some(changed));
assert!(!chat.is_current_misalignment_review(&review));
// Present-but-invalid findings withdraw the old authorization.
for explanation in [None, Some(" ".to_string()), Some("a".repeat(65537))] {
chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details()));
let replacement = select_review(&mut chat, &mut rx);
let mut details = review_details();
details.detailed_explanation = explanation;
chat.on_misalignment_error(Some("turn-1".to_string()), Some(details));
assert!(!chat.is_current_misalignment_review(&replacement));
}
for steer in [None, Some(" ".to_string()), Some("a".repeat(1025))] {
let mut details = review_details();
details.steer =
steer.map(|message| codex_app_server_protocol::MisalignmentSteer { message });
chat.on_misalignment_error(Some("turn-1".to_string()), Some(details));
let review = select_review(&mut chat, &mut rx);
chat.show_misalignment_review_confirmation(Arc::clone(&review));
assert!(review.continuation_message().is_none());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(
!std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::ContinueMisalignment(_)))
);
assert!(!chat.bottom_pane.composer_input_enabled());
}
handle_turn_started(&mut chat, "turn-2");
chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details()));
assert!(!chat.has_misalignment_policy_violation());
assert!(chat.bottom_pane.composer_input_enabled());
// A queued Back action cannot reopen the precaution after a new turn starts.
chat.show_misalignment_policy_precaution();
assert!(!chat.bottom_pane.has_active_view());
}
+2 -2
View File
@@ -874,7 +874,7 @@ impl TranscriptOverlay {
let mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
"to quit",
"close",
)];
if self.highlight_cell.is_some() {
pairs.push((
@@ -994,7 +994,7 @@ impl StaticOverlay {
render_navigation_hints(line1, buf, &self.view.keymap);
let pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
"to quit",
"close",
)];
render_key_hints(line2, buf, &pairs);
}
@@ -14,7 +14,7 @@ Buffer {
" 1 -removed ",
"───────────────────────────────────────── 100% ─",
" ↑/↓ to scroll pgup/pgdn to page home/end to",
" q to quit ",
" q close ",
" ",
],
styles: [
@@ -34,6 +34,6 @@ Buffer {
x: 4, y: 6, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 12, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 10, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -14,7 +14,7 @@ Buffer {
"m-updated.txt (+1 -1) ",
"─────────────────────────────────────────── 0% ─",
" ↑/↓ to scroll pgup/pgdn to page home/end to",
" q to quit ",
" q close ",
" ",
],
styles: [
@@ -35,6 +35,6 @@ Buffer {
x: 18, y: 6, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 10, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -13,7 +13,7 @@ before:
~
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
after:
@@ -27,4 +27,4 @@ after:
~
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
@@ -10,5 +10,5 @@ expression: term.backend()
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit "
" q close "
" "
@@ -8,5 +8,5 @@ expression: term.backend()
"rendered within a narrow"
"─────────────────── 0% ─"
" ↑/↓ to scroll pgup/pg"
" q to quit "
" q close "
" "
@@ -12,4 +12,4 @@ expression: snapshot
2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
@@ -10,4 +10,4 @@ last
~
───────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page hom
q to quit esc to edit prev
q close esc to edit prev
@@ -14,5 +14,5 @@ expression: term.backend()
"tail "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" q close esc to edit prev "
" "
@@ -11,7 +11,7 @@ recent transcript
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
--- partial ---
/ T R A N S C R I P T / / / / / / / partial history | PgUp for earlier
@@ -22,7 +22,7 @@ recent transcript
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
--- failed ---
/ T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry
@@ -33,7 +33,7 @@ recent transcript
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
--- complete ---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / start of history
@@ -44,4 +44,4 @@ recent transcript
~
───────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
@@ -10,5 +10,5 @@ expression: term.backend()
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" q close esc to edit prev "
" "
@@ -10,5 +10,5 @@ expression: term.backend()
"gamma "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" q close esc to edit prev "
" "
@@ -31,7 +31,7 @@ before:
~
───────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev
after:
@@ -63,4 +63,4 @@ next message
~
───────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
q close esc to edit prev