Stop TUI chats on misalignment policy violations (#39261)

## Why

When the app server reports a misalignment policy violation, the TUI must stop the affected chat rather than allow more work to continue in it.

## What changed

- Finalize the active turn, clear queued and draft input, disable the composer, and reject further operations and interactive requests for the stopped chat.
- Interrupt an active side conversation when its parent reports the violation, and prevent queued turns, forks, approvals, or goal resumption from restarting work.
- Show a non-dismissible precaution view that directs users to start a new chat or resume another one, with agent command center navigation for remote sessions.

## Testing

Added coverage for stopped-chat behavior, parent/side-thread handling, blocked goal resumption, navigation actions, and narrow and remote-session rendering.

GitOrigin-RevId: 0268c46a19294a17765ea4099f389f15ffaaf030
This commit is contained in:
Eric Traut
2026-08-18 18:50:58 +00:00
committed by copyberry
parent 3006151ad4
commit 785ecd7452
16 changed files with 452 additions and 3 deletions
+22
View File
@@ -13,6 +13,7 @@ use crate::config_update::format_config_error;
use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome;
use crate::pager_overlay::TranscriptHistoryState;
use crate::session_resume::cwds_differ;
use codex_app_server_protocol::ThreadGoalStatus;
#[cfg(target_os = "windows")]
use codex_config::types::WindowsSandboxModeToml;
@@ -25,6 +26,24 @@ impl App {
app_server: &mut AppServerSession,
event: AppEvent,
) -> Result<AppRunControl> {
if self.chat_widget.has_misalignment_policy_violation()
&& matches!(
event,
AppEvent::OpenAgentPicker
| AppEvent::SelectAgentThread(_)
| AppEvent::StartSide { .. }
| AppEvent::ForkCurrentSession { .. }
| AppEvent::ForkSessionForPromptEdit { .. }
| AppEvent::SetThreadGoalDraft { .. }
| AppEvent::SetThreadGoalStatus {
status: ThreadGoalStatus::Active,
..
}
)
{
return Ok(AppRunControl::Continue);
}
match event {
AppEvent::SkillsListLoaded { ref cwd, .. }
| AppEvent::PluginMentionsLoaded { ref cwd, .. }
@@ -35,6 +54,9 @@ impl App {
/*initial_user_message*/ None, name,
)
.await;
if self.chat_widget.has_misalignment_policy_violation() {
self.chat_widget.show_misalignment_policy_precaution();
}
}
AppEvent::ChangeWorkingDirectory {
thread_id,
@@ -1,11 +1,15 @@
use super::*;
use crate::chatwidget::UserMessage;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn turn_start_failure_is_shown_without_exiting() -> Result<()> {
@@ -57,6 +61,142 @@ async fn turn_start_failure_is_shown_without_exiting() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn misalignment_policy_blocks_queued_turns_and_goal_resumption() -> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
let session = started.session.clone();
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app_server
.thread_goal_set(
thread_id,
Some("Do not restart this stopped goal".to_string()),
Some(ThreadGoalStatus::Blocked),
/*token_budget*/ None,
)
.await?;
app.chat_widget
.restore_user_message_to_composer("queued before terminal failure".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let queued_turn = next_user_turn_op(&mut op_rx);
let failed_turn = Turn {
error: Some(AppServerTurnError {
message: "misalignment policy violation".to_string(),
codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation),
additional_details: None,
}),
..test_turn("turn-1", TurnStatus::Failed, Vec::new())
};
app.enqueue_primary_thread_session(session, vec![failed_turn])
.await?;
let precaution = render_bottom_popup(&app.chat_widget, /*width*/ 80);
let AppServerRequestId::Integer(next_request_id) = app_server.next_request_id() else {
unreachable!("embedded app-server request IDs are integers");
};
app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id)
.await;
for event in [
AppEvent::CodexOp(queued_turn),
AppEvent::CodexOp(crate::app_command::AppCommand::ExecApproval {
id: "queued-approval".to_string(),
turn_id: None,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
}),
AppEvent::SetThreadGoalStatus {
thread_id,
status: ThreadGoalStatus::Active,
},
AppEvent::SetThreadGoalDraft {
thread_id,
draft: crate::goal_files::GoalDraft {
objective: "Do not replace this stopped goal".to_string(),
..Default::default()
},
mode: crate::app_event::ThreadGoalSetMode::ReplaceExisting,
},
AppEvent::StartSide {
parent_thread_id: thread_id,
user_message: Some("Do not fork this stopped thread".into()),
},
AppEvent::ForkCurrentSession { name: None },
AppEvent::OpenAgentPicker,
AppEvent::SelectAgentThread(thread_id),
] {
app.handle_event(&mut tui, &mut app_server, event).await?;
}
assert_eq!(
app_server.next_request_id(),
AppServerRequestId::Integer(next_request_id + 1)
);
assert_eq!(
render_bottom_popup(&app.chat_widget, /*width*/ 80),
precaution
);
assert_eq!(
app_server
.thread_goal_get(thread_id)
.await?
.goal
.unwrap()
.status,
ThreadGoalStatus::Blocked
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn misalignment_policy_in_parent_stops_active_side_conversation() -> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.chat_widget.handle_thread_session(test_thread_session(
side_thread_id,
app.config.cwd.to_path_buf(),
));
app.chat_widget
.restore_user_message_to_composer("pending side turn".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
while op_rx.try_recv().is_ok() {}
app.enqueue_thread_notification(
parent_thread_id,
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "misalignment policy violation".to_string(),
codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation),
additional_details: None,
},
will_retry: false,
thread_id: parent_thread_id.to_string(),
turn_id: "parent-turn".to_string(),
}),
)
.await?;
assert!(app.chat_widget.has_misalignment_policy_violation());
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.contains("Chat stopped as a precaution")
);
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
Ok(())
}
#[tokio::test]
async fn unsupported_legacy_permissions_are_shown_without_exiting() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
@@ -56,6 +56,10 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) {
if self.chat_widget.has_misalignment_policy_violation() {
return;
}
let result = app_server.thread_goal_get(thread_id).await;
if self.current_displayed_thread_id() != Some(thread_id) {
return;
+26
View File
@@ -346,6 +346,10 @@ impl App {
}
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
if self.chat_widget.has_misalignment_policy_violation() {
return;
}
match request {
ThreadInteractiveRequest::AppLink(params) => {
self.chat_widget.open_app_link_view(params);
@@ -444,6 +448,10 @@ impl App {
thread_id: ThreadId,
op: AppCommand,
) -> Result<()> {
if self.chat_widget.rejects_misalignment_policy_op(&op) {
return Ok(());
}
crate::session_log::log_outbound_op(&op);
if self
@@ -960,6 +968,24 @@ impl App {
if self.abandoned_side_threads.contains(&thread_id) {
return Ok(());
}
let misalignment_policy_violation =
match &notification {
ServerNotification::Error(notification) if !notification.will_retry => {
notification.error.codex_error_info.as_ref()
}
ServerNotification::TurnCompleted(notification) => notification
.turn
.error
.as_ref()
.and_then(|error| error.codex_error_info.as_ref()),
_ => None,
} == Some(&AppServerCodexErrorInfo::MisalignmentPolicyViolation);
if misalignment_policy_violation && self.active_side_parent_thread_id() == Some(thread_id) {
if self.chat_widget.is_user_turn_pending_or_running() {
self.chat_widget.submit_op(AppCommand::Interrupt);
}
self.chat_widget.on_misalignment_policy_violation();
}
if matches!(
notification,
ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_)
+5
View File
@@ -364,6 +364,7 @@ use self::interrupts::InterruptManager;
mod keymap_picker;
mod mcp_startup;
use self::mcp_startup::McpStartupStatus;
mod misalignment_policy;
mod pets;
mod session_flow;
mod session_header;
@@ -674,6 +675,7 @@ pub(crate) struct ChatWidget {
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
misalignment_policy_violation: bool,
normal_placeholder_text: String,
side_placeholder_text: String,
forked_from: Option<ThreadId>,
@@ -1790,6 +1792,9 @@ impl ChatWidget {
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
if self.rejects_misalignment_policy_op(&op) {
return false;
}
if self.blocks_direct_input
&& matches!(
&op,
@@ -203,6 +203,7 @@ impl ChatWidget {
thread_rename_block_message: None,
active_side_conversation: false,
blocks_direct_input: false,
misalignment_policy_violation: false,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
forked_from: None,
+5 -2
View File
@@ -110,6 +110,9 @@ impl ChatWidget {
action: QueuedInputAction,
pending_pastes: Vec<(String, String)>,
) {
if self.misalignment_policy_violation {
return;
}
let should_run_now = self.is_session_configured()
&& !self.is_user_turn_pending_or_running()
&& !self.input_queue.suppress_queue_autosend;
@@ -135,7 +138,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.input_queue.suppress_queue_autosend {
if self.misalignment_policy_violation || self.input_queue.suppress_queue_autosend {
return false;
}
if self.blocks_direct_input {
@@ -178,7 +181,7 @@ impl ChatWidget {
submitted_follow_up
}
pub(super) fn is_user_turn_pending_or_running(&self) -> bool {
pub(crate) fn is_user_turn_pending_or_running(&self) -> bool {
self.input_queue.user_turn_pending_start
|| self.turn_lifecycle.agent_turn_running
|| self.review.is_review_mode
@@ -99,6 +99,9 @@ impl ChatWidget {
history_record: UserMessageHistoryRecord,
shell_escape_policy: ShellEscapePolicy,
) -> (bool, Option<AppCommand>) {
if self.misalignment_policy_violation {
return (false, None);
}
if !self.is_session_configured() {
tracing::warn!("cannot submit user message before session is configured; queueing");
self.input_queue
@@ -0,0 +1,83 @@
//! Terminal precaution UI for misalignment policy violations.
use super::*;
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
}
pub(crate) fn rejects_misalignment_policy_op(&self, op: &AppCommand) -> bool {
self.misalignment_policy_violation
&& !matches!(
op,
AppCommand::Interrupt
| AppCommand::CleanBackgroundTerminals
| AppCommand::OverrideTurnContext { .. }
| AppCommand::ReloadUserConfig
| AppCommand::ListSkills { .. }
| AppCommand::SetThreadName { .. }
)
}
pub(crate) fn on_misalignment_policy_violation(&mut self) {
if self.misalignment_policy_violation {
return;
}
self.misalignment_policy_violation = true;
self.input_queue.clear();
self.finalize_turn();
self.refresh_pending_input_preview();
self.bottom_pane.drain_pending_submission_state();
self.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
self.bottom_pane.set_composer_input_enabled(
/*enabled*/ false,
Some(MISALIGNMENT_POLICY_TITLE.to_string()),
);
self.show_misalignment_policy_precaution();
}
pub(crate) fn show_misalignment_policy_precaution(&mut self) {
let mut items = vec![
SelectionItem {
name: "New chat".to_string(),
actions: vec![Box::new(|tx| tx.send(AppEvent::NewSession { name: None }))],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Resume another chat".to_string(),
actions: vec![Box::new(|tx| tx.send(AppEvent::OpenResumePicker))],
..Default::default()
},
];
if self.remote_connection.is_some() {
items.insert(
1,
SelectionItem {
name: "Agent command center".to_string(),
actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAgentsOverview))],
..Default::default()
},
);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(
Paragraph::new(vec![
Line::from(MISALIGNMENT_POLICY_TITLE).bold(),
Line::from(MISALIGNMENT_POLICY_DESCRIPTION).dim(),
])
.wrap(Wrap { trim: false }),
),
items,
allow_cancel: false,
..Default::default()
});
}
}
@@ -11,6 +11,10 @@ impl ChatWidget {
request: ServerRequest,
replay_kind: Option<ReplayKind>,
) {
if self.misalignment_policy_violation {
return;
}
let id = request.id().to_string();
match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/misalignment_policy_tests.rs
expression: popup
---
Chat stopped as a precaution
We couldn’t confirm the agent was acting safely and following your
instructions. To continue working, start or resume another chat.
› 1. New chat
2. Resume another chat
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/misalignment_policy_tests.rs
expression: "render_bottom_popup(&chat, 38)"
---
Chat stopped as a precaution
We couldn’t confirm the agent was
acting safely and following your
instructions. To continue working,
start or resume another chat.
› 1. New chat
2. Resume another chat
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/misalignment_policy_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
Chat stopped as a precaution
We couldn’t confirm the agent was acting safely and following your
instructions. To continue working, start or resume another chat.
› 1. New chat
2. Agent command center
3. Resume another chat
+2
View File
@@ -243,6 +243,8 @@ mod guardian;
pub(crate) mod helpers;
mod history_replay;
mod mcp_startup;
#[path = "tests/misalignment_policy_tests.rs"]
mod misalignment_policy;
mod permissions;
mod plan_mode;
#[path = "tests/plugin_catalog_tests.rs"]
@@ -0,0 +1,121 @@
use super::*;
use pretty_assertions::assert_eq;
const ERROR_MESSAGE: &str = "Responses API returned misalignment_policy_violation";
#[tokio::test]
async fn misalignment_policy_failure_stops_the_thread_and_renders_once() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
chat.handle_server_notification(
ServerNotification::ModelSafetyBufferingUpdated(ModelSafetyBufferingUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
model: "current-model".to_string(),
use_cases: Vec::new(),
reasons: Vec::new(),
show_buffering_ui: true,
faster_model: None,
}),
/*replay_kind*/ None,
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("thinking a bit more"));
chat.queue_user_message(UserMessage::from("queued follow-up"));
chat.bottom_pane
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
drain_insert_history(&mut rx);
handle_error(
&mut chat,
ERROR_MESSAGE,
Some(CodexErrorInfo::MisalignmentPolicyViolation),
);
assert!(!chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.composer_input_enabled());
assert!(chat.queued_user_message_texts().is_empty());
assert_eq!(chat.bottom_pane.composer_text_with_pending(), "");
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("misalignment_policy_violation", popup);
assert_chatwidget_snapshot!(
"misalignment_policy_violation_narrow",
render_bottom_popup(&chat, /*width*/ 38)
);
chat.submit_user_message(UserMessage::from("direct follow-up"));
chat.queue_user_message(UserMessage::from("queued follow-up"));
assert!(!chat.submit_op(Op::Compact));
assert!(!chat.submit_op(Op::run_user_shell_command("echo unsafe".to_string())));
assert!(!chat.maybe_send_next_queued_input());
assert!(chat.queued_user_message_texts().is_empty());
assert_no_submit_op(&mut op_rx);
chat.handle_server_request(
ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(1),
params: ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
item_id: "input-1".to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
},
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: app_server_turn(
"turn-1",
AppServerTurnStatus::Failed,
/*duration_ms*/ None,
Some(AppServerTurnError {
message: ERROR_MESSAGE.to_string(),
codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation),
additional_details: None,
}),
),
}),
/*replay_kind*/ None,
);
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), popup);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: None }));
assert!(rx.try_recv().is_err());
chat.show_misalignment_policy_precaution();
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), popup);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: None }));
chat.show_misalignment_policy_precaution();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker));
assert!(chat.bottom_pane.has_active_view());
chat.remote_connection = Some(crate::status::remote_connection::RemoteConnectionStatus {
address: "wss://remote.example.com".to_string(),
version: "v1.0.0".to_string(),
});
chat.show_misalignment_policy_precaution();
assert_chatwidget_snapshot!(
"misalignment_policy_violation_remote",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAgentsOverview));
assert!(chat.bottom_pane.has_active_view());
}
+3 -1
View File
@@ -443,7 +443,9 @@ impl ChatWidget {
message: String,
codex_error_info: Option<AppServerCodexErrorInfo>,
) {
if codex_error_info
if codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) {
self.on_misalignment_policy_violation();
} else if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
{