mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
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:
1 parent
4df8027a97
commit
70a0b1eef8
29 files changed
+186
-269
No files matched your search
@@ -414,9 +414,6 @@ impl App {
|
|||||||
.add_error_message(format!("Failed to retry with a faster model: {err}"));
|
.add_error_message(format!("Failed to retry with a faster model: {err}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppEvent::RestoreCancelledTurn(prompt) => {
|
|
||||||
self.apply_cancelled_turn_edit(prompt);
|
|
||||||
}
|
|
||||||
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
|
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
|
||||||
self.append_message_history_entry(thread_id, text);
|
self.append_message_history_entry(thread_id, text);
|
||||||
}
|
}
|
||||||
|
|||||||
-5
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
source: tui/src/app/tests.rs
|
|
||||||
expression: app.chat_widget.composer_text_with_pending()
|
|
||||||
---
|
|
||||||
edit me
|
|
||||||
@@ -561,6 +561,61 @@ async fn replay_thread_snapshot_restores_draft_and_queued_input() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() {
|
||||||
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||||||
|
app.thread_event_channels.insert(
|
||||||
|
thread_id,
|
||||||
|
ThreadEventChannel::new_with_session(
|
||||||
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
||||||
|
session.clone(),
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.activate_thread_channel(thread_id).await;
|
||||||
|
app.chat_widget.handle_thread_session(session);
|
||||||
|
let default_mode = CollaborationModeMask {
|
||||||
|
name: "Default".to_string(),
|
||||||
|
mode: None,
|
||||||
|
model: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
developer_instructions: None,
|
||||||
|
};
|
||||||
|
app.chat_widget
|
||||||
|
.submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone());
|
||||||
|
let expected_input_state = app
|
||||||
|
.chat_widget
|
||||||
|
.capture_thread_input_state()
|
||||||
|
.expect("expected thread input state");
|
||||||
|
|
||||||
|
app.store_active_thread_receiver().await;
|
||||||
|
let snapshot = {
|
||||||
|
let channel = app
|
||||||
|
.thread_event_channels
|
||||||
|
.get(&thread_id)
|
||||||
|
.expect("thread channel should exist");
|
||||||
|
let store = channel.store.lock().await;
|
||||||
|
assert_eq!(store.input_state, Some(expected_input_state.clone()));
|
||||||
|
store.snapshot()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||||
|
chat_widget.handle_thread_session(test_thread_session(
|
||||||
|
ThreadId::new(),
|
||||||
|
test_path_buf("/tmp/other-project"),
|
||||||
|
));
|
||||||
|
chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode);
|
||||||
|
app.chat_widget = chat_widget;
|
||||||
|
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.chat_widget.capture_thread_input_state(),
|
||||||
|
Some(expected_input_state)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn active_turn_id_for_thread_uses_snapshot_turns() {
|
async fn active_turn_id_for_thread_uses_snapshot_turns() {
|
||||||
let mut app = make_test_app().await;
|
let mut app = make_test_app().await;
|
||||||
@@ -5283,61 +5338,6 @@ async fn backtrack_remote_image_only_selection_clears_existing_composer_draft()
|
|||||||
assert_eq!(rollback_turns, Some(1));
|
assert_eq!(rollback_turns, Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cancelled_turn_edit_restores_prompt_and_rolls_back_latest_turn() {
|
|
||||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
||||||
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
||||||
message: "original".to_string(),
|
|
||||||
text_elements: Vec::new(),
|
|
||||||
local_image_paths: Vec::new(),
|
|
||||||
remote_image_urls: Vec::new(),
|
|
||||||
}) as Arc<dyn HistoryCell>];
|
|
||||||
let prompt = crate::chatwidget::UserMessage {
|
|
||||||
text: "edit me".to_string(),
|
|
||||||
local_images: Vec::new(),
|
|
||||||
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
|
||||||
text_elements: Vec::new(),
|
|
||||||
mention_bindings: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
app.apply_cancelled_turn_edit(prompt);
|
|
||||||
|
|
||||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "edit me");
|
|
||||||
assert_snapshot!(
|
|
||||||
"cancelled_turn_edit_restores_composer",
|
|
||||||
app.chat_widget.composer_text_with_pending()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.chat_widget.remote_image_urls(),
|
|
||||||
vec!["https://example.com/edit.png".to_string()]
|
|
||||||
);
|
|
||||||
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn first_cancelled_turn_edit_restores_prompt_without_local_history() {
|
|
||||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
||||||
let prompt = crate::chatwidget::UserMessage {
|
|
||||||
text: "edit first prompt".to_string(),
|
|
||||||
local_images: Vec::new(),
|
|
||||||
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
|
||||||
text_elements: Vec::new(),
|
|
||||||
mention_bindings: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
app.apply_cancelled_turn_edit(prompt);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
app.chat_widget.composer_text_with_pending(),
|
|
||||||
"edit first prompt"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
app.chat_widget.remote_image_urls(),
|
|
||||||
vec!["https://example.com/edit.png".to_string()]
|
|
||||||
);
|
|
||||||
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
||||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ impl App {
|
|||||||
op: &AppCommand,
|
op: &AppCommand,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match op {
|
match op {
|
||||||
AppCommand::Interrupt { .. } => {
|
AppCommand::Interrupt => {
|
||||||
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
||||||
let mut interrupt_turn_id = turn_id;
|
let mut interrupt_turn_id = turn_id;
|
||||||
for retried_after_turn_mismatch in [false, true] {
|
for retried_after_turn_mismatch in [false, true] {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ use std::sync::Arc;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::app_command::AppCommand;
|
use crate::app_command::AppCommand;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::chatwidget::UserMessage;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::history_cell::AgentMessageCell;
|
use crate::history_cell::AgentMessageCell;
|
||||||
use crate::history_cell::SessionInfoCell;
|
use crate::history_cell::SessionInfoCell;
|
||||||
@@ -239,38 +238,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) {
|
|
||||||
let user_total = user_count(&self.transcript_cells);
|
|
||||||
let selection = BacktrackSelection {
|
|
||||||
nth_user_message: user_total.saturating_sub(1),
|
|
||||||
prefill: prompt.text.clone(),
|
|
||||||
text_elements: prompt.text_elements.clone(),
|
|
||||||
local_image_paths: prompt
|
|
||||||
.local_images
|
|
||||||
.iter()
|
|
||||||
.map(|image| image.path.clone())
|
|
||||||
.collect(),
|
|
||||||
remote_image_urls: prompt.remote_image_urls.clone(),
|
|
||||||
};
|
|
||||||
if user_total == 0 {
|
|
||||||
if self.backtrack.pending_rollback.is_some() {
|
|
||||||
self.chat_widget
|
|
||||||
.add_error_message("Backtrack rollback already in progress.".to_string());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
|
|
||||||
selection,
|
|
||||||
thread_id: self.chat_widget.thread_id(),
|
|
||||||
});
|
|
||||||
self.chat_widget
|
|
||||||
.submit_op(AppCommand::thread_rollback(/*num_turns*/ 1));
|
|
||||||
self.chat_widget.restore_user_message_to_composer(prompt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.apply_backtrack_rollback(selection);
|
|
||||||
self.chat_widget.restore_user_message_to_composer(prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
||||||
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||||
let _ = tui.enter_alt_screen();
|
let _ = tui.enter_alt_screen();
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ use serde_json::Value;
|
|||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub(crate) enum AppCommand {
|
pub(crate) enum AppCommand {
|
||||||
Interrupt {
|
Interrupt,
|
||||||
behavior: InterruptBehavior,
|
|
||||||
},
|
|
||||||
CleanBackgroundTerminals,
|
CleanBackgroundTerminals,
|
||||||
RunUserShellCommand {
|
RunUserShellCommand {
|
||||||
command: String,
|
command: String,
|
||||||
@@ -104,23 +102,9 @@ pub(crate) enum AppCommand {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
||||||
pub(crate) enum InterruptBehavior {
|
|
||||||
Default,
|
|
||||||
RestorePromptIfNoOutput,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppCommand {
|
impl AppCommand {
|
||||||
pub(crate) fn interrupt() -> Self {
|
pub(crate) fn interrupt() -> Self {
|
||||||
Self::Interrupt {
|
Self::Interrupt
|
||||||
behavior: InterruptBehavior::Default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn interrupt_and_restore_prompt_if_no_output() -> Self {
|
|
||||||
Self::Interrupt {
|
|
||||||
behavior: InterruptBehavior::RestorePromptIfNoOutput,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clean_background_terminals() -> Self {
|
pub(crate) fn clean_background_terminals() -> Self {
|
||||||
|
|||||||
@@ -259,9 +259,6 @@ pub(crate) enum AppEvent {
|
|||||||
/// bubbling channels through layers of widgets.
|
/// bubbling channels through layers of widgets.
|
||||||
CodexOp(AppCommand),
|
CodexOp(AppCommand),
|
||||||
|
|
||||||
/// Restore an output-free interrupted turn into the composer and roll it back.
|
|
||||||
RestoreCancelledTurn(UserMessage),
|
|
||||||
|
|
||||||
/// Approve one retry of a recent auto-review denial selected in the TUI.
|
/// Approve one retry of a recent auto-review denial selected in the TUI.
|
||||||
ApproveRecentAutoReviewDenial {
|
ApproveRecentAutoReviewDenial {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ impl AppEventSender {
|
|||||||
self.send(AppEvent::CodexOp(AppCommand::interrupt()));
|
self.send(AppEvent::CodexOp(AppCommand::interrupt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn interrupt_and_restore_prompt_if_no_output(&self) {
|
|
||||||
self.send(AppEvent::CodexOp(
|
|
||||||
AppCommand::interrupt_and_restore_prompt_if_no_output(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn compact(&self) {
|
pub(crate) fn compact(&self) {
|
||||||
self.send(AppEvent::CodexOp(AppCommand::compact()));
|
self.send(AppEvent::CodexOp(AppCommand::compact()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2655,7 +2655,7 @@ mod tests {
|
|||||||
|
|
||||||
while let Ok(ev) = rx.try_recv() {
|
while let Ok(ev) = rx.try_recv() {
|
||||||
assert!(
|
assert!(
|
||||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||||
"expected Esc to not send Op::Interrupt when dismissing skill popup"
|
"expected Esc to not send Op::Interrupt when dismissing skill popup"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2694,7 +2694,7 @@ mod tests {
|
|||||||
|
|
||||||
while let Ok(ev) = rx.try_recv() {
|
while let Ok(ev) = rx.try_recv() {
|
||||||
assert!(
|
assert!(
|
||||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||||
"expected Esc to not send Op::Interrupt while command popup is active"
|
"expected Esc to not send Op::Interrupt while command popup is active"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2741,7 +2741,7 @@ mod tests {
|
|||||||
|
|
||||||
while let Ok(ev) = rx.try_recv() {
|
while let Ok(ev) = rx.try_recv() {
|
||||||
assert!(
|
assert!(
|
||||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||||
"expected Esc to not send Op::Interrupt while typing `/agent`"
|
"expected Esc to not send Op::Interrupt while typing `/agent`"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2786,7 +2786,7 @@ mod tests {
|
|||||||
|
|
||||||
while let Ok(ev) = rx.try_recv() {
|
while let Ok(ev) = rx.try_recv() {
|
||||||
assert!(
|
assert!(
|
||||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||||
"expected Esc release after dismissing agent picker to not interrupt"
|
"expected Esc release after dismissing agent picker to not interrupt"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2816,7 +2816,7 @@ mod tests {
|
|||||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
|
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||||
"expected Esc to send Op::Interrupt while a task is running"
|
"expected Esc to send Op::Interrupt while a task is running"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2840,7 +2840,7 @@ mod tests {
|
|||||||
|
|
||||||
pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
|
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||||
"expected configured key to interrupt while `/agent` is being edited"
|
"expected configured key to interrupt while `/agent` is being edited"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ pub(crate) struct ChatWidget {
|
|||||||
// order.
|
// order.
|
||||||
suppress_initial_user_message_submit: bool,
|
suppress_initial_user_message_submit: bool,
|
||||||
input_queue: InputQueueState,
|
input_queue: InputQueueState,
|
||||||
cancel_edit: CancelEditState,
|
safety_buffering_prompt: Option<UserMessage>,
|
||||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||||
chat_keymap: ChatKeymap,
|
chat_keymap: ChatKeymap,
|
||||||
/// Keybinding to show for popping the most-recently queued message back
|
/// Keybinding to show for popping the most-recently queued message back
|
||||||
@@ -787,13 +787,6 @@ pub(crate) enum InterruptedTurnNoticeMode {
|
|||||||
Suppress,
|
Suppress,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct CancelEditState {
|
|
||||||
prompt: Option<UserMessage>,
|
|
||||||
eligible: bool,
|
|
||||||
armed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) enum ReplayKind {
|
pub(crate) enum ReplayKind {
|
||||||
ResumeInitialMessages,
|
ResumeInitialMessages,
|
||||||
@@ -1219,9 +1212,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||||
if self.turn_lifecycle.agent_turn_running && !cell.display_lines(u16::MAX).is_empty() {
|
|
||||||
self.record_visible_turn_activity();
|
|
||||||
}
|
|
||||||
// Keep the placeholder session header as the active cell until real session info arrives,
|
// Keep the placeholder session header as the active cell until real session info arrives,
|
||||||
// so we can merge headers instead of committing a duplicate box to history.
|
// so we can merge headers instead of committing a duplicate box to history.
|
||||||
let keep_placeholder_header_active = !self.is_session_configured()
|
let keep_placeholder_header_active = !self.is_session_configured()
|
||||||
@@ -1840,12 +1830,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) {
|
pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) {
|
||||||
if let AppCommand::Interrupt { behavior } = op
|
if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running {
|
||||||
&& self.turn_lifecycle.agent_turn_running
|
|
||||||
{
|
|
||||||
if *behavior == crate::app_command::InterruptBehavior::RestorePromptIfNoOutput {
|
|
||||||
self.arm_cancel_edit();
|
|
||||||
}
|
|
||||||
if let Some(controller) = self.stream_controller.as_mut() {
|
if let Some(controller) = self.stream_controller.as_mut() {
|
||||||
controller.clear_queue();
|
controller.clear_queue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
|
pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ThreadItem::CommandExecution {
|
let ThreadItem::CommandExecution {
|
||||||
id,
|
id,
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ impl ChatWidget {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
|
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
|
||||||
input_queue: InputQueueState::default(),
|
input_queue: InputQueueState::default(),
|
||||||
cancel_edit: CancelEditState::default(),
|
safety_buffering_prompt: None,
|
||||||
chat_keymap,
|
chat_keymap,
|
||||||
queued_message_edit_hint_binding,
|
queued_message_edit_hint_binding,
|
||||||
show_welcome_banner: is_first_run,
|
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) {
|
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_answer_stream_with_separator();
|
||||||
self.flush_completed_hook_output();
|
self.flush_completed_hook_output();
|
||||||
match self.active_hook_cell.as_mut() {
|
match self.active_hook_cell.as_mut() {
|
||||||
|
|||||||
@@ -6,38 +6,6 @@ use super::user_messages::remap_colliding_paste_placeholders;
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl ChatWidget {
|
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) {
|
pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) {
|
||||||
self.suppress_initial_user_message_submit = suppressed;
|
self.suppress_initial_user_message_submit = suppressed;
|
||||||
}
|
}
|
||||||
@@ -147,15 +115,12 @@ impl ChatWidget {
|
|||||||
/// When there are queued user messages, restore them into the composer
|
/// When there are queued user messages, restore them into the composer
|
||||||
/// separated by newlines rather than auto-submitting the next one.
|
/// separated by newlines rather than auto-submitting the next one.
|
||||||
pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
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.
|
// Finalize, log a gentle prompt, and clear running state.
|
||||||
self.finalize_turn();
|
self.finalize_turn();
|
||||||
let send_pending_steers_immediately =
|
let send_pending_steers_immediately =
|
||||||
self.input_queue.submit_pending_steers_after_interrupt;
|
self.input_queue.submit_pending_steers_after_interrupt;
|
||||||
self.input_queue.submit_pending_steers_after_interrupt = false;
|
self.input_queue.submit_pending_steers_after_interrupt = false;
|
||||||
if cancelled_prompt.is_none()
|
if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress {
|
||||||
&& self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress
|
|
||||||
{
|
|
||||||
if send_pending_steers_immediately {
|
if send_pending_steers_immediately {
|
||||||
self.add_to_history(history_cell::new_info_event(
|
self.add_to_history(history_cell::new_info_event(
|
||||||
"Model interrupted to submit steer instructions.".to_owned(),
|
"Model interrupted to submit steer instructions.".to_owned(),
|
||||||
@@ -189,11 +154,6 @@ impl ChatWidget {
|
|||||||
self.restore_composer_state(combined);
|
self.restore_composer_state(combined);
|
||||||
}
|
}
|
||||||
self.refresh_pending_input_preview();
|
self.refresh_pending_input_preview();
|
||||||
if let Some(prompt) = cancelled_prompt {
|
|
||||||
self.app_event_tx
|
|
||||||
.send(AppEvent::RestoreCancelledTurn(prompt));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +311,7 @@ impl ChatWidget {
|
|||||||
};
|
};
|
||||||
Some(ThreadInputState {
|
Some(ThreadInputState {
|
||||||
composer: composer.has_content().then_some(composer),
|
composer: composer.has_content().then_some(composer),
|
||||||
|
safety_buffering_prompt: self.safety_buffering_prompt.clone(),
|
||||||
pending_steers: self
|
pending_steers: self
|
||||||
.input_queue
|
.input_queue
|
||||||
.pending_steers
|
.pending_steers
|
||||||
@@ -389,6 +350,7 @@ impl ChatWidget {
|
|||||||
if let Some(input_state) = input_state {
|
if let Some(input_state) = input_state {
|
||||||
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
||||||
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
||||||
|
self.safety_buffering_prompt = input_state.safety_buffering_prompt;
|
||||||
self.turn_lifecycle
|
self.turn_lifecycle
|
||||||
.restore_running(input_state.agent_turn_running, Instant::now());
|
.restore_running(input_state.agent_turn_running, Instant::now());
|
||||||
self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start;
|
self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start;
|
||||||
@@ -434,6 +396,7 @@ impl ChatWidget {
|
|||||||
} else {
|
} else {
|
||||||
self.turn_lifecycle
|
self.turn_lifecycle
|
||||||
.restore_running(/*running*/ false, Instant::now());
|
.restore_running(/*running*/ false, Instant::now());
|
||||||
|
self.safety_buffering_prompt = None;
|
||||||
self.input_queue.clear();
|
self.input_queue.clear();
|
||||||
self.restore_composer_state(Default::default());
|
self.restore_composer_state(Default::default());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if render_in_history {
|
if render_in_history {
|
||||||
self.record_cancel_edit_candidate(UserMessage {
|
self.safety_buffering_prompt = Some(UserMessage {
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
local_images: local_images.clone(),
|
local_images: local_images.clone(),
|
||||||
remote_image_urls: remote_image_urls.clone(),
|
remote_image_urls: remote_image_urls.clone(),
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ impl ChatWidget {
|
|||||||
self.quit_shortcut_expires_at = None;
|
self.quit_shortcut_expires_at = None;
|
||||||
self.quit_shortcut_key = None;
|
self.quit_shortcut_key = None;
|
||||||
self.bottom_pane.clear_quit_shortcut_hint();
|
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();
|
self.pause_active_goal_for_interrupt();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -421,9 +421,7 @@ impl ChatWidget {
|
|||||||
|
|
||||||
self.arm_quit_shortcut(key);
|
self.arm_quit_shortcut(key);
|
||||||
|
|
||||||
if self.is_cancellable_work_active()
|
if self.is_cancellable_work_active() && self.submit_op(AppCommand::interrupt()) {
|
||||||
&& self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output())
|
|
||||||
{
|
|
||||||
self.pause_active_goal_for_interrupt();
|
self.pause_active_goal_for_interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,18 +64,17 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_safety_buffering_retry(&mut self) {
|
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.last_rendered_user_message_display = None;
|
||||||
self.finalize_turn();
|
self.finalize_turn();
|
||||||
self.cancel_edit = cancel_edit;
|
self.safety_buffering_prompt = safety_buffering_prompt;
|
||||||
self.input_queue.user_turn_pending_start = true;
|
self.input_queue.user_turn_pending_start = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn fail_safety_buffering_retry(&mut self) {
|
pub(crate) fn fail_safety_buffering_retry(&mut self) {
|
||||||
self.input_queue.user_turn_pending_start = false;
|
self.input_queue.user_turn_pending_start = false;
|
||||||
self.clear_safety_buffering();
|
self.clear_safety_buffering();
|
||||||
let prompt = self.cancel_edit.prompt.take();
|
let prompt = self.safety_buffering_prompt.take();
|
||||||
self.clear_cancel_edit();
|
|
||||||
if let Some(prompt) = prompt {
|
if let Some(prompt) = prompt {
|
||||||
self.restore_user_message_to_composer(prompt);
|
self.restore_user_message_to_composer(prompt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,6 @@ impl ChatWidget {
|
|||||||
if self.active_mode_kind() != ModeKind::Plan {
|
if self.active_mode_kind() != ModeKind::Plan {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !delta.is_empty() {
|
|
||||||
self.record_visible_turn_activity();
|
|
||||||
}
|
|
||||||
if !self.transcript.plan_item_active {
|
if !self.transcript.plan_item_active {
|
||||||
self.transcript.plan_item_active = true;
|
self.transcript.plan_item_active = true;
|
||||||
self.transcript.plan_delta_buffer.clear();
|
self.transcript.plan_delta_buffer.clear();
|
||||||
@@ -394,7 +391,6 @@ impl ChatWidget {
|
|||||||
#[inline]
|
#[inline]
|
||||||
pub(super) fn handle_streaming_delta(&mut self, delta: String) {
|
pub(super) fn handle_streaming_delta(&mut self, delta: String) {
|
||||||
if !delta.is_empty() {
|
if !delta.is_empty() {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
self.mark_safety_buffering_agent_message_started();
|
self.mark_safety_buffering_agent_message_started();
|
||||||
}
|
}
|
||||||
if self.stream_controller.is_none() {
|
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));
|
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]
|
#[tokio::test]
|
||||||
async fn safety_buffering_without_retry_shows_short_app_message() {
|
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 (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::FileSystemSpecialPath;
|
||||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -928,67 +927,89 @@ async fn empty_enter_during_task_does_not_queue() {
|
|||||||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn interrupted_history(
|
||||||
async fn output_free_interrupted_turn_requests_prompt_restore() {
|
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
prompt: &str,
|
||||||
let prompt = UserMessage::from("revise this prompt");
|
) -> (bool, String) {
|
||||||
chat.record_cancel_edit_candidate(prompt.clone());
|
let mut saw_prompt = false;
|
||||||
handle_turn_started(&mut chat, "turn-1");
|
let mut history = Vec::new();
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||||
assert_matches!(
|
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
|
||||||
op_rx.try_recv(),
|
assert_eq!(cell.message, prompt);
|
||||||
Ok(Op::Interrupt {
|
saw_prompt = true;
|
||||||
behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput,
|
}
|
||||||
})
|
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");
|
(saw_prompt, history)
|
||||||
|
|
||||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn visible_output_prevents_cancelled_turn_prompt_restore() {
|
async fn output_free_esc_interrupt_keeps_prompt_and_opens_blank_composer() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
|
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");
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
chat.on_agent_message_delta("visible output".to_string());
|
chat.bottom_pane.ensure_status_indicator();
|
||||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
|
||||||
|
|
||||||
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() {
|
let mut saw_prompt = false;
|
||||||
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
|
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");
|
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]
|
#[tokio::test]
|
||||||
async fn patch_activity_prevents_cancelled_turn_prompt_restore() {
|
async fn output_free_ctrl_c_interrupt_keeps_prompt_and_opens_blank_composer() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
|
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");
|
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");
|
handle_turn_interrupted(&mut chat, "turn-1");
|
||||||
|
|
||||||
while let Ok(event) = rx.try_recv() {
|
let (saw_prompt, interrupted_history) = interrupted_history(&mut rx, prompt);
|
||||||
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
|
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]
|
#[tokio::test]
|
||||||
@@ -1014,7 +1035,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
|
|||||||
chat.handle_key_event(esc);
|
chat.handle_key_event(esc);
|
||||||
|
|
||||||
match op_rx.try_recv() {
|
match op_rx.try_recv() {
|
||||||
Ok(Op::Interrupt { .. }) => {}
|
Ok(Op::Interrupt) => {}
|
||||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
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));
|
chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||||
|
|
||||||
match op_rx.try_recv() {
|
match op_rx.try_recv() {
|
||||||
Ok(Op::Interrupt { .. }) => {}
|
Ok(Op::Interrupt) => {}
|
||||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
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 {
|
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||||
composer: None,
|
composer: None,
|
||||||
|
safety_buffering_prompt: Some(UserMessage::from("buffered prompt")),
|
||||||
pending_steers: VecDeque::new(),
|
pending_steers: VecDeque::new(),
|
||||||
pending_steer_history_records: VecDeque::new(),
|
pending_steer_history_records: VecDeque::new(),
|
||||||
pending_steer_compare_keys: 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.agent_turn_running);
|
||||||
assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
|
assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
|
||||||
assert!(chat.bottom_pane.is_task_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);
|
chat.restore_thread_input_state(/*input_state*/ None);
|
||||||
|
|
||||||
assert!(!chat.turn_lifecycle.agent_turn_running);
|
assert!(!chat.turn_lifecycle.agent_turn_running);
|
||||||
assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
|
assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
|
||||||
assert!(!chat.bottom_pane.is_task_running());
|
assert!(!chat.bottom_pane.is_task_running());
|
||||||
|
assert_eq!(chat.safety_buffering_prompt, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -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>) {
|
pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||||
loop {
|
loop {
|
||||||
match op_rx.try_recv() {
|
match op_rx.try_recv() {
|
||||||
Ok(Op::Interrupt { .. }) => return,
|
Ok(Op::Interrupt) => return,
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"),
|
Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"),
|
||||||
Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"),
|
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 {
|
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||||
composer: None,
|
composer: None,
|
||||||
|
safety_buffering_prompt: None,
|
||||||
pending_steers,
|
pending_steers,
|
||||||
pending_steer_history_records: VecDeque::new(),
|
pending_steer_history_records: VecDeque::new(),
|
||||||
pending_steer_compare_keys,
|
pending_steer_compare_keys,
|
||||||
|
|||||||
+12
@@ -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));
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||||
match op_rx.try_recv() {
|
match op_rx.try_recv() {
|
||||||
Ok(Op::Interrupt { .. }) => {}
|
Ok(Op::Interrupt) => {}
|
||||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
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));
|
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);
|
assert_goal_paused_event(&mut rx, thread_id);
|
||||||
|
|
||||||
update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused);
|
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);
|
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);
|
assert_goal_paused_event(&mut rx, thread_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ use codex_utils_path_uri::LegacyAppPathString;
|
|||||||
|
|
||||||
impl ChatWidget {
|
impl ChatWidget {
|
||||||
pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
|
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));
|
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) {
|
pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
self.flush_answer_stream_with_separator();
|
self.flush_answer_stream_with_separator();
|
||||||
self.add_to_history(history_cell::new_view_image_tool_call(
|
self.add_to_history(history_cell::new_view_image_tool_call(
|
||||||
path,
|
path,
|
||||||
@@ -23,7 +21,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_image_generation_begin(&mut self) {
|
pub(super) fn on_image_generation_begin(&mut self) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
self.flush_answer_stream_with_separator();
|
self.flush_answer_stream_with_separator();
|
||||||
if self.bottom_pane.is_task_running() {
|
if self.bottom_pane.is_task_running() {
|
||||||
self.bottom_pane.ensure_status_indicator();
|
self.bottom_pane.ensure_status_indicator();
|
||||||
@@ -72,7 +69,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_web_search_begin(&mut self, call_id: String) {
|
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_answer_stream_with_separator();
|
||||||
self.flush_active_cell();
|
self.flush_active_cell();
|
||||||
self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call(
|
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) {
|
pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ThreadItem::CollabAgentToolCall {
|
let ThreadItem::CollabAgentToolCall {
|
||||||
id, tool, status, ..
|
id, tool, status, ..
|
||||||
} = &item
|
} = &item
|
||||||
@@ -151,7 +146,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) {
|
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) {
|
if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) {
|
||||||
self.on_collab_event(cell);
|
self.on_collab_event(cell);
|
||||||
}
|
}
|
||||||
@@ -171,7 +165,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) {
|
pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ThreadItem::McpToolCall {
|
let ThreadItem::McpToolCall {
|
||||||
id,
|
id,
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use super::*;
|
|||||||
|
|
||||||
impl ChatWidget {
|
impl ChatWidget {
|
||||||
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
|
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ev2 = ev.clone();
|
let ev2 = ev.clone();
|
||||||
self.defer_or_handle(
|
self.defer_or_handle(
|
||||||
|q| q.push_exec_approval(ev),
|
|q| q.push_exec_approval(ev),
|
||||||
@@ -20,7 +19,6 @@ impl ChatWidget {
|
|||||||
_id: String,
|
_id: String,
|
||||||
ev: ApplyPatchApprovalRequestEvent,
|
ev: ApplyPatchApprovalRequestEvent,
|
||||||
) {
|
) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ev2 = ev.clone();
|
let ev2 = ev.clone();
|
||||||
self.defer_or_handle(
|
self.defer_or_handle(
|
||||||
|q| q.push_apply_patch_approval(ev),
|
|q| q.push_apply_patch_approval(ev),
|
||||||
@@ -258,7 +256,6 @@ impl ChatWidget {
|
|||||||
request_id: AppServerRequestId,
|
request_id: AppServerRequestId,
|
||||||
params: McpServerElicitationRequestParams,
|
params: McpServerElicitationRequestParams,
|
||||||
) {
|
) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let request_id2 = request_id.clone();
|
let request_id2 = request_id.clone();
|
||||||
let params2 = params.clone();
|
let params2 = params.clone();
|
||||||
self.defer_or_handle(
|
self.defer_or_handle(
|
||||||
@@ -268,7 +265,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ev2 = ev.clone();
|
let ev2 = ev.clone();
|
||||||
self.defer_or_handle(
|
self.defer_or_handle(
|
||||||
|q| q.push_user_input(ev),
|
|q| q.push_user_input(ev),
|
||||||
@@ -277,7 +273,6 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
||||||
self.record_visible_turn_activity();
|
|
||||||
let ev2 = ev.clone();
|
let ev2 = ev.clone();
|
||||||
self.defer_or_handle(
|
self.defer_or_handle(
|
||||||
|q| q.push_request_permissions(ev),
|
|q| q.push_request_permissions(ev),
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ impl ChatWidget {
|
|||||||
self.plan_stream_controller = None;
|
self.plan_stream_controller = None;
|
||||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||||
self.status_state.pending_status_indicator_restore = false;
|
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_branch_refresh();
|
||||||
self.request_status_line_git_summary_refresh();
|
self.request_status_line_git_summary_refresh();
|
||||||
self.maybe_show_pending_rate_limit_prompt();
|
self.maybe_show_pending_rate_limit_prompt();
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl ThreadComposerState {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub(crate) struct ThreadInputState {
|
pub(crate) struct ThreadInputState {
|
||||||
pub(super) composer: Option<ThreadComposerState>,
|
pub(super) composer: Option<ThreadComposerState>,
|
||||||
|
pub(super) safety_buffering_prompt: Option<UserMessage>,
|
||||||
pub(super) pending_steers: VecDeque<UserMessage>,
|
pub(super) pending_steers: VecDeque<UserMessage>,
|
||||||
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||||
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ impl StatusIndicatorWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn interrupt(&self) {
|
pub(crate) fn interrupt(&self) {
|
||||||
self.app_event_tx
|
self.app_event_tx.interrupt();
|
||||||
.interrupt_and_restore_prompt_if_no_output();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the animated header label (left of the brackets).
|
/// Update the animated header label (left of the brackets).
|
||||||
|
|||||||
Reference in new issue
Block a user