Keep interrupted prompts in conversation history (#33198)

## What changed

- Leave an output-free interrupted prompt in the transcript and open a blank
  composer for the user's next instruction.
- Use the normal interruption notice for both `Esc` and `Ctrl-C` interrupts.
- Track safety-buffering prompts separately so failed retries can still restore
  the prompt, including after switching between threads.

## Testing

- Cover output-free `Esc` and `Ctrl-C` interrupts, failed safety-buffer retries,
  and restoration of safety-buffer state from thread snapshots.

GitOrigin-RevId: 30b368d00dfdb981d06d4c95ce3f252389b7917c
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-15 01:07:29 +00:00
1 parent 4df8027a97
commit 70a0b1eef8
29 files changed
+186 -269

No files matched your search

@@ -240,7 +240,6 @@ impl ChatWidget {
}
pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
let ThreadItem::CommandExecution {
id,
command,
+1 -1
View File
@@ -199,7 +199,7 @@ impl ChatWidget {
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
input_queue: InputQueueState::default(),
cancel_edit: CancelEditState::default(),
safety_buffering_prompt: None,
chat_keymap,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
@@ -15,7 +15,6 @@ impl ChatWidget {
}
pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
match self.active_hook_cell.as_mut() {
+4 -41
View File
@@ -6,38 +6,6 @@ use super::user_messages::remap_colliding_paste_placeholders;
use super::*;
impl ChatWidget {
pub(super) fn record_cancel_edit_candidate(&mut self, prompt: UserMessage) {
self.cancel_edit.prompt = Some(prompt);
self.cancel_edit.eligible = true;
self.cancel_edit.armed = false;
}
pub(super) fn record_visible_turn_activity(&mut self) {
self.cancel_edit.eligible = false;
self.cancel_edit.armed = false;
}
pub(super) fn arm_cancel_edit(&mut self) {
self.cancel_edit.armed = self.cancel_edit.eligible
&& self.cancel_edit.prompt.is_some()
&& self.bottom_pane.composer_is_empty()
&& self.input_queue.pending_steers.is_empty()
&& !self.has_queued_follow_up_messages()
&& !self.active_side_conversation;
}
fn take_armed_cancel_edit_prompt(&mut self, reason: TurnAbortReason) -> Option<UserMessage> {
(reason == TurnAbortReason::Interrupted
&& self.cancel_edit.armed
&& self.cancel_edit.eligible)
.then(|| self.cancel_edit.prompt.take())
.flatten()
}
pub(super) fn clear_cancel_edit(&mut self) {
self.cancel_edit = CancelEditState::default();
}
pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) {
self.suppress_initial_user_message_submit = suppressed;
}
@@ -147,15 +115,12 @@ impl ChatWidget {
/// When there are queued user messages, restore them into the composer
/// separated by newlines rather than auto-submitting the next one.
pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
let cancelled_prompt = self.take_armed_cancel_edit_prompt(reason);
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn();
let send_pending_steers_immediately =
self.input_queue.submit_pending_steers_after_interrupt;
self.input_queue.submit_pending_steers_after_interrupt = false;
if cancelled_prompt.is_none()
&& self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress
{
if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress {
if send_pending_steers_immediately {
self.add_to_history(history_cell::new_info_event(
"Model interrupted to submit steer instructions.".to_owned(),
@@ -189,11 +154,6 @@ impl ChatWidget {
self.restore_composer_state(combined);
}
self.refresh_pending_input_preview();
if let Some(prompt) = cancelled_prompt {
self.app_event_tx
.send(AppEvent::RestoreCancelledTurn(prompt));
}
self.request_redraw();
}
@@ -351,6 +311,7 @@ impl ChatWidget {
};
Some(ThreadInputState {
composer: composer.has_content().then_some(composer),
safety_buffering_prompt: self.safety_buffering_prompt.clone(),
pending_steers: self
.input_queue
.pending_steers
@@ -389,6 +350,7 @@ impl ChatWidget {
if let Some(input_state) = input_state {
self.current_collaboration_mode = input_state.current_collaboration_mode;
self.active_collaboration_mask = input_state.active_collaboration_mask;
self.safety_buffering_prompt = input_state.safety_buffering_prompt;
self.turn_lifecycle
.restore_running(input_state.agent_turn_running, Instant::now());
self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start;
@@ -434,6 +396,7 @@ impl ChatWidget {
} else {
self.turn_lifecycle
.restore_running(/*running*/ false, Instant::now());
self.safety_buffering_prompt = None;
self.input_queue.clear();
self.restore_composer_state(Default::default());
}
@@ -390,7 +390,7 @@ impl ChatWidget {
}
if render_in_history {
self.record_cancel_edit_candidate(UserMessage {
self.safety_buffering_prompt = Some(UserMessage {
text: text.clone(),
local_images: local_images.clone(),
remote_image_urls: remote_image_urls.clone(),
+2 -4
View File
@@ -403,7 +403,7 @@ impl ChatWidget {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) {
if self.submit_op(AppCommand::interrupt()) {
self.pause_active_goal_for_interrupt();
}
} else {
@@ -421,9 +421,7 @@ impl ChatWidget {
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active()
&& self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output())
{
if self.is_cancellable_work_active() && self.submit_op(AppCommand::interrupt()) {
self.pause_active_goal_for_interrupt();
}
}
@@ -64,18 +64,17 @@ impl ChatWidget {
}
pub(crate) fn prepare_safety_buffering_retry(&mut self) {
let cancel_edit = std::mem::take(&mut self.cancel_edit);
let safety_buffering_prompt = self.safety_buffering_prompt.take();
self.last_rendered_user_message_display = None;
self.finalize_turn();
self.cancel_edit = cancel_edit;
self.safety_buffering_prompt = safety_buffering_prompt;
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();
let prompt = self.safety_buffering_prompt.take();
if let Some(prompt) = prompt {
self.restore_user_message_to_composer(prompt);
}
-4
View File
@@ -121,9 +121,6 @@ impl ChatWidget {
if self.active_mode_kind() != ModeKind::Plan {
return;
}
if !delta.is_empty() {
self.record_visible_turn_activity();
}
if !self.transcript.plan_item_active {
self.transcript.plan_item_active = true;
self.transcript.plan_delta_buffer.clear();
@@ -394,7 +391,6 @@ impl ChatWidget {
#[inline]
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() {
@@ -190,6 +190,20 @@ async fn safety_buffering_remains_visible_until_turn_completes() {
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
}
#[tokio::test]
async fn failed_safety_buffering_retry_restores_submitted_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
start_safety_buffering_test_turn(&mut chat, &mut op_rx);
chat.prepare_safety_buffering_retry();
assert!(chat.input_queue.user_turn_pending_start);
chat.fail_safety_buffering_retry();
assert_eq!(chat.composer_text_with_pending(), "Explain the request");
assert!(!chat.input_queue.user_turn_pending_start);
}
#[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;
@@ -7,7 +7,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::VecDeque;
#[tokio::test]
@@ -928,67 +927,89 @@ async fn empty_enter_during_task_does_not_queue() {
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
async fn output_free_interrupted_turn_requests_prompt_restore() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let prompt = UserMessage::from("revise this prompt");
chat.record_cancel_edit_candidate(prompt.clone());
handle_turn_started(&mut chat, "turn-1");
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
assert_matches!(
op_rx.try_recv(),
Ok(Op::Interrupt {
behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput,
})
fn interrupted_history(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
prompt: &str,
) -> (bool, String) {
let mut saw_prompt = false;
let mut history = Vec::new();
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
assert_eq!(cell.message, prompt);
saw_prompt = true;
}
history.push(lines_to_single_string(&cell.display_lines(/*width*/ 80)));
}
}
let history = history.join("\n");
assert!(
history.contains("Conversation interrupted - tell the model what to do differently."),
"expected normal interruption notice, got {history:?}"
);
handle_turn_interrupted(&mut chat, "turn-1");
assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
(saw_prompt, history)
}
#[tokio::test]
async fn visible_output_prevents_cancelled_turn_prompt_restore() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
async fn output_free_esc_interrupt_keeps_prompt_and_opens_blank_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let prompt = "revise this prompt";
chat.thread_id = Some(ThreadId::new());
chat.submit_user_message(UserMessage::from(prompt));
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
handle_turn_started(&mut chat, "turn-1");
chat.on_agent_message_delta("visible output".to_string());
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
chat.bottom_pane.ensure_status_indicator();
handle_turn_interrupted(&mut chat, "turn-1");
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert!(chat.bottom_pane.should_interrupt_running_task(esc));
chat.handle_key_event(esc);
while let Ok(event) = rx.try_recv() {
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
let mut saw_prompt = false;
loop {
match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
assert_eq!(cell.message, prompt);
saw_prompt = true;
}
}
Ok(AppEvent::CodexOp(Op::Interrupt)) => break,
Ok(_) => {}
Err(error) => panic!("expected Esc interrupt command, got {error:?}"),
}
}
}
#[tokio::test]
async fn thinking_status_keeps_cancelled_turn_prompt_restore_eligible() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let prompt = UserMessage::from("revise this prompt");
chat.record_cancel_edit_candidate(prompt.clone());
handle_turn_started(&mut chat, "turn-1");
chat.on_agent_reasoning_delta("**Thinking**".to_string());
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
handle_turn_interrupted(&mut chat, "turn-1");
assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
let (prompt_after_interrupt, _) = interrupted_history(&mut rx, prompt);
assert!(saw_prompt || prompt_after_interrupt);
assert!(chat.bottom_pane.composer_is_empty());
}
#[tokio::test]
async fn patch_activity_prevents_cancelled_turn_prompt_restore() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
async fn output_free_ctrl_c_interrupt_keeps_prompt_and_opens_blank_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let prompt = "revise this prompt";
chat.thread_id = Some(ThreadId::new());
chat.submit_user_message(UserMessage::from(prompt));
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
handle_turn_started(&mut chat, "turn-1");
chat.on_patch_apply_begin(HashMap::new());
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
next_interrupt_op(&mut op_rx);
handle_turn_interrupted(&mut chat, "turn-1");
while let Ok(event) = rx.try_recv() {
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
}
let (saw_prompt, interrupted_history) = interrupted_history(&mut rx, prompt);
assert!(saw_prompt);
assert!(chat.bottom_pane.composer_is_empty());
insta::assert_snapshot!(
"output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer",
format!(
"history:\n{interrupted_history}\ncomposer:\n{}",
chat.bottom_pane.composer_text()
)
);
}
#[tokio::test]
@@ -1014,7 +1035,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
chat.handle_key_event(esc);
match op_rx.try_recv() {
Ok(Op::Interrupt { .. }) => {}
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
@@ -1040,7 +1061,7 @@ async fn pending_steer_interrupt_uses_remapped_binding() {
chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
match op_rx.try_recv() {
Ok(Op::Interrupt { .. }) => {}
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
@@ -1053,6 +1074,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
safety_buffering_prompt: Some(UserMessage::from("buffered prompt")),
pending_steers: VecDeque::new(),
pending_steer_history_records: VecDeque::new(),
pending_steer_compare_keys: VecDeque::new(),
@@ -1070,12 +1092,19 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
assert!(chat.turn_lifecycle.agent_turn_running);
assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
assert!(chat.bottom_pane.is_task_running());
assert_eq!(
chat.capture_thread_input_state()
.expect("thread input state")
.safety_buffering_prompt,
Some(UserMessage::from("buffered prompt"))
);
chat.restore_thread_input_state(/*input_state*/ None);
assert!(!chat.turn_lifecycle.agent_turn_running);
assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
assert!(!chat.bottom_pane.is_task_running());
assert_eq!(chat.safety_buffering_prompt, None);
}
#[tokio::test]
+1 -1
View File
@@ -229,7 +229,7 @@ pub(super) fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op
pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
loop {
match op_rx.try_recv() {
Ok(Op::Interrupt { .. }) => return,
Ok(Op::Interrupt) => return,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"),
Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"),
@@ -375,6 +375,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
safety_buffering_prompt: None,
pending_steers,
pending_steer_history_records: VecDeque::new(),
pending_steer_compare_keys,
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/composer_submission.rs
expression: "format!(\"history:\\n{interrupted_history}\\ncomposer:\\n{}\",\nchat.bottom_pane.composer_text())"
---
history:
› revise this prompt
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
composer:
@@ -1955,7 +1955,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt { .. }) => {}
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
@@ -2019,7 +2019,7 @@ async fn esc_interrupt_pauses_active_goal_turn() {
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. })));
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
assert_goal_paused_event(&mut rx, thread_id);
update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused);
@@ -2056,7 +2056,7 @@ async fn request_user_input_interrupt_pauses_active_goal_turn() {
chat.handle_key_event(key_event);
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. })));
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
assert_goal_paused_event(&mut rx, thread_id);
}
}
@@ -8,12 +8,10 @@ use codex_utils_path_uri::LegacyAppPathString;
impl ChatWidget {
pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
self.record_visible_turn_activity();
self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd));
}
pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_view_image_tool_call(
path,
@@ -23,7 +21,6 @@ impl ChatWidget {
}
pub(super) fn on_image_generation_begin(&mut self) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
if self.bottom_pane.is_task_running() {
self.bottom_pane.ensure_status_indicator();
@@ -72,7 +69,6 @@ impl ChatWidget {
}
pub(super) fn on_web_search_begin(&mut self, call_id: String) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.flush_active_cell();
self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call(
@@ -119,7 +115,6 @@ impl ChatWidget {
}
pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
let ThreadItem::CollabAgentToolCall {
id, tool, status, ..
} = &item
@@ -151,7 +146,6 @@ impl ChatWidget {
}
pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) {
self.on_collab_event(cell);
}
@@ -171,7 +165,6 @@ impl ChatWidget {
}
pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
let ThreadItem::McpToolCall {
id,
server,
@@ -7,7 +7,6 @@ use super::*;
impl ChatWidget {
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
self.record_visible_turn_activity();
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_exec_approval(ev),
@@ -20,7 +19,6 @@ impl ChatWidget {
_id: String,
ev: ApplyPatchApprovalRequestEvent,
) {
self.record_visible_turn_activity();
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_apply_patch_approval(ev),
@@ -258,7 +256,6 @@ impl ChatWidget {
request_id: AppServerRequestId,
params: McpServerElicitationRequestParams,
) {
self.record_visible_turn_activity();
let request_id2 = request_id.clone();
let params2 = params.clone();
self.defer_or_handle(
@@ -268,7 +265,6 @@ impl ChatWidget {
}
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
self.record_visible_turn_activity();
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_user_input(ev),
@@ -277,7 +273,6 @@ impl ChatWidget {
}
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
self.record_visible_turn_activity();
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_request_permissions(ev),
+1 -1
View File
@@ -332,7 +332,7 @@ impl ChatWidget {
self.plan_stream_controller = None;
self.request_pending_usage_output_insertion_after_stream_shutdown();
self.status_state.pending_status_indicator_restore = false;
self.clear_cancel_edit();
self.safety_buffering_prompt = None;
self.request_status_line_branch_refresh();
self.request_status_line_git_summary_refresh();
self.maybe_show_pending_rate_limit_prompt();
@@ -121,6 +121,7 @@ impl ThreadComposerState {
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadInputState {
pub(super) composer: Option<ThreadComposerState>,
pub(super) safety_buffering_prompt: Option<UserMessage>,
pub(super) pending_steers: VecDeque<UserMessage>,
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,