mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
codex: address PR review feedback (#18290)
This commit is contained in:
+172
-62
@@ -87,6 +87,7 @@ use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
|
||||
use codex_app_server_protocol::CollabAgentTool;
|
||||
use codex_app_server_protocol::CollabAgentToolCallStatus;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ErrorNotification;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
@@ -888,15 +889,9 @@ pub(crate) struct ChatWidget {
|
||||
// The bottom pane shows these above queued drafts until core records the
|
||||
// corresponding user message item.
|
||||
pending_steers: VecDeque<PendingSteer>,
|
||||
// Set after submitting a new turn before the matching TurnStarted arrives.
|
||||
pending_turn_start_after_submit: bool,
|
||||
// Set after submitting a standalone `!` command while no model turn is
|
||||
// running. The following TurnStarted is the user shell turn.
|
||||
pending_standalone_user_shell_command: bool,
|
||||
// Active turn id for a standalone `!` command. While this is set, Enter
|
||||
// should queue follow-up input for the next turn instead of steering the
|
||||
// shell command turn.
|
||||
standalone_user_shell_turn_id: Option<String>,
|
||||
// Local turn submission state that has not fully settled in the replayed
|
||||
// thread snapshot yet.
|
||||
turn_submission_state: TurnSubmissionState,
|
||||
// When set, the next interrupt should resubmit all pending steers as one
|
||||
// fresh user turn instead of restoring them into the composer.
|
||||
submit_pending_steers_after_interrupt: bool,
|
||||
@@ -1069,6 +1064,40 @@ impl ThreadComposerState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
enum TurnSubmissionState {
|
||||
#[default]
|
||||
Idle,
|
||||
AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind,
|
||||
preceding_turn_id: Option<String>,
|
||||
},
|
||||
RunningStandaloneUserShell {
|
||||
turn_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TurnSubmissionState {
|
||||
fn clear_for_finished_turn(&mut self, finished_turn_id: Option<&str>) {
|
||||
let should_clear = match self {
|
||||
Self::Idle => false,
|
||||
Self::AwaitingTurnStart { .. } => true,
|
||||
Self::RunningStandaloneUserShell { turn_id } => finished_turn_id
|
||||
.map(|finished_turn_id| finished_turn_id == turn_id)
|
||||
.unwrap_or(true),
|
||||
};
|
||||
if should_clear {
|
||||
*self = Self::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TurnSubmissionKind {
|
||||
UserTurn,
|
||||
StandaloneUserShell,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
@@ -1079,9 +1108,7 @@ pub(crate) struct ThreadInputState {
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
task_running: bool,
|
||||
agent_turn_running: bool,
|
||||
pending_turn_start_after_submit: bool,
|
||||
pending_standalone_user_shell_command: bool,
|
||||
standalone_user_shell_turn_id: Option<String>,
|
||||
turn_submission_state: TurnSubmissionState,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
@@ -2371,30 +2398,34 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn note_turn_started(&mut self, turn_id: &str) {
|
||||
self.pending_turn_start_after_submit = false;
|
||||
if self.pending_standalone_user_shell_command {
|
||||
self.pending_standalone_user_shell_command = false;
|
||||
self.standalone_user_shell_turn_id = Some(turn_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_standalone_user_shell_turn(&mut self, turn_id: Option<&str>) {
|
||||
let should_clear = match (turn_id, self.standalone_user_shell_turn_id.as_deref()) {
|
||||
(Some(completed), Some(active)) => completed == active,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
self.turn_submission_state = match std::mem::take(&mut self.turn_submission_state) {
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
..
|
||||
} => TurnSubmissionState::RunningStandaloneUserShell {
|
||||
turn_id: turn_id.to_string(),
|
||||
},
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::UserTurn,
|
||||
..
|
||||
} => TurnSubmissionState::Idle,
|
||||
TurnSubmissionState::Idle | TurnSubmissionState::RunningStandaloneUserShell { .. } => {
|
||||
TurnSubmissionState::Idle
|
||||
}
|
||||
};
|
||||
if should_clear {
|
||||
self.standalone_user_shell_turn_id = None;
|
||||
}
|
||||
if self.standalone_user_shell_turn_id.is_none() {
|
||||
self.pending_standalone_user_shell_command = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn should_hold_user_input_for_standalone_shell_turn(&self) -> bool {
|
||||
self.pending_standalone_user_shell_command
|
||||
|| (self.standalone_user_shell_turn_id.is_some() && self.bottom_pane.is_task_running())
|
||||
matches!(
|
||||
&self.turn_submission_state,
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
..
|
||||
}
|
||||
) || (matches!(
|
||||
&self.turn_submission_state,
|
||||
TurnSubmissionState::RunningStandaloneUserShell { .. }
|
||||
) && self.bottom_pane.is_task_running())
|
||||
}
|
||||
|
||||
fn on_task_complete(&mut self, last_agent_message: Option<String>, from_replay: bool) {
|
||||
@@ -2871,9 +2902,7 @@ impl ChatWidget {
|
||||
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
|
||||
/// and should continue to drive the bottom-pane running indicator while it is in progress.
|
||||
fn finalize_turn(&mut self) {
|
||||
self.pending_turn_start_after_submit = false;
|
||||
self.pending_standalone_user_shell_command = false;
|
||||
self.standalone_user_shell_turn_id = None;
|
||||
self.turn_submission_state = TurnSubmissionState::Idle;
|
||||
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
||||
self.finalize_active_cell_as_failed();
|
||||
// Reset running state and clear streaming buffers.
|
||||
@@ -2893,6 +2922,12 @@ impl ChatWidget {
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
}
|
||||
|
||||
fn finalize_turn_preserving_submission_state(&mut self) {
|
||||
let turn_submission_state = std::mem::take(&mut self.turn_submission_state);
|
||||
self.finalize_turn();
|
||||
self.turn_submission_state = turn_submission_state;
|
||||
}
|
||||
|
||||
fn on_server_overloaded_error(&mut self, message: String) {
|
||||
self.submit_pending_steers_after_interrupt = false;
|
||||
self.finalize_turn();
|
||||
@@ -3309,9 +3344,7 @@ impl ChatWidget {
|
||||
active_collaboration_mask: self.active_collaboration_mask.clone(),
|
||||
task_running: self.bottom_pane.is_task_running(),
|
||||
agent_turn_running: self.agent_turn_running,
|
||||
pending_turn_start_after_submit: self.pending_turn_start_after_submit,
|
||||
pending_standalone_user_shell_command: self.pending_standalone_user_shell_command,
|
||||
standalone_user_shell_turn_id: self.standalone_user_shell_turn_id.clone(),
|
||||
turn_submission_state: self.turn_submission_state.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3321,10 +3354,7 @@ impl ChatWidget {
|
||||
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
||||
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
||||
self.agent_turn_running = input_state.agent_turn_running;
|
||||
self.pending_turn_start_after_submit = input_state.pending_turn_start_after_submit;
|
||||
self.pending_standalone_user_shell_command =
|
||||
input_state.pending_standalone_user_shell_command;
|
||||
self.standalone_user_shell_turn_id = input_state.standalone_user_shell_turn_id;
|
||||
self.turn_submission_state = input_state.turn_submission_state;
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.refresh_model_dependent_surfaces();
|
||||
if let Some(composer) = input_state.composer {
|
||||
@@ -3368,9 +3398,7 @@ impl ChatWidget {
|
||||
self.queued_user_messages = input_state.queued_user_messages;
|
||||
} else {
|
||||
self.agent_turn_running = false;
|
||||
self.pending_turn_start_after_submit = false;
|
||||
self.pending_standalone_user_shell_command = false;
|
||||
self.standalone_user_shell_turn_id = None;
|
||||
self.turn_submission_state = TurnSubmissionState::Idle;
|
||||
self.pending_steers.clear();
|
||||
self.rejected_steers_queue.clear();
|
||||
self.set_remote_image_urls(Vec::new());
|
||||
@@ -4985,9 +5013,7 @@ impl ChatWidget {
|
||||
queued_user_messages: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
pending_turn_start_after_submit: false,
|
||||
pending_standalone_user_shell_command: false,
|
||||
standalone_user_shell_turn_id: None,
|
||||
turn_submission_state: TurnSubmissionState::Idle,
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
@@ -5476,13 +5502,18 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
let starts_standalone_user_shell_turn = !self.agent_turn_running
|
||||
&& !self.pending_turn_start_after_submit
|
||||
&& !matches!(
|
||||
&self.turn_submission_state,
|
||||
TurnSubmissionState::AwaitingTurnStart { .. }
|
||||
)
|
||||
&& !self.should_hold_user_input_for_standalone_shell_turn();
|
||||
if self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()))
|
||||
&& starts_standalone_user_shell_turn
|
||||
{
|
||||
self.pending_turn_start_after_submit = true;
|
||||
self.pending_standalone_user_shell_command = true;
|
||||
self.turn_submission_state = TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
preceding_turn_id: self.last_turn_id.clone(),
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5660,7 +5691,10 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
if render_in_history {
|
||||
self.pending_turn_start_after_submit = true;
|
||||
self.turn_submission_state = TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::UserTurn,
|
||||
preceding_turn_id: self.last_turn_id.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
// Persist the text to cross-session message history. Mentions are
|
||||
@@ -5758,6 +5792,73 @@ impl ChatWidget {
|
||||
/// avoid triggering side effects. Event ids are passed as `None` to
|
||||
/// distinguish replayed events from live ones.
|
||||
pub(crate) fn replay_thread_turns(&mut self, turns: Vec<Turn>, replay_kind: ReplayKind) {
|
||||
let replay_reconciliation_start = match &self.turn_submission_state {
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
preceding_turn_id: Some(preceding_turn_id),
|
||||
..
|
||||
} => turns
|
||||
.iter()
|
||||
.position(|turn| &turn.id == preceding_turn_id)
|
||||
.map(|position| position + 1)
|
||||
.unwrap_or(turns.len()),
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
preceding_turn_id: None,
|
||||
..
|
||||
}
|
||||
| TurnSubmissionState::Idle
|
||||
| TurnSubmissionState::RunningStandaloneUserShell { .. } => 0,
|
||||
};
|
||||
let replay_reconciliation_turns = &turns[replay_reconciliation_start..];
|
||||
let in_progress_turn_id = replay_reconciliation_turns
|
||||
.iter()
|
||||
.find(|turn| matches!(turn.status, TurnStatus::InProgress))
|
||||
.map(|turn| turn.id.clone());
|
||||
let terminal_replay_turn = replay_reconciliation_turns.iter().find(|turn| {
|
||||
matches!(
|
||||
turn.status,
|
||||
TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed
|
||||
)
|
||||
});
|
||||
// Reconcile local submit/queue latches from the replayed snapshot as a whole before
|
||||
// rendering individual turns. Per-item replay is historical UI reconstruction; this is the
|
||||
// single place where snapshot state is allowed to unblock queued follow-up input.
|
||||
if let Some(turn_id) = in_progress_turn_id {
|
||||
self.note_turn_started(&turn_id);
|
||||
} else if let Some(turn) = terminal_replay_turn {
|
||||
let has_user_shell_command = turn.items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ThreadItem::CommandExecution {
|
||||
source: CommandExecutionSource::UserShell,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
match &self.turn_submission_state {
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::UserTurn,
|
||||
..
|
||||
} => self
|
||||
.turn_submission_state
|
||||
.clear_for_finished_turn(Some(&turn.id)),
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
..
|
||||
} if has_user_shell_command => self
|
||||
.turn_submission_state
|
||||
.clear_for_finished_turn(Some(&turn.id)),
|
||||
TurnSubmissionState::RunningStandaloneUserShell { turn_id }
|
||||
if turn_id == &turn.id =>
|
||||
{
|
||||
self.turn_submission_state
|
||||
.clear_for_finished_turn(Some(&turn.id));
|
||||
}
|
||||
TurnSubmissionState::Idle
|
||||
| TurnSubmissionState::AwaitingTurnStart { .. }
|
||||
| TurnSubmissionState::RunningStandaloneUserShell { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
for turn in turns {
|
||||
let Turn {
|
||||
id: turn_id,
|
||||
@@ -5770,9 +5871,6 @@ impl ChatWidget {
|
||||
} = turn;
|
||||
if matches!(status, TurnStatus::InProgress) {
|
||||
self.last_non_retry_error = None;
|
||||
if self.pending_standalone_user_shell_command {
|
||||
self.note_turn_started(&turn_id);
|
||||
}
|
||||
self.on_task_started();
|
||||
}
|
||||
for item in items {
|
||||
@@ -6487,8 +6585,8 @@ impl ChatWidget {
|
||||
state_cleanup,
|
||||
TurnCompletionStateCleanup::ClearPendingMarkers
|
||||
) {
|
||||
self.pending_turn_start_after_submit = false;
|
||||
self.clear_standalone_user_shell_turn(Some(¬ification.turn.id));
|
||||
self.turn_submission_state
|
||||
.clear_for_finished_turn(Some(¬ification.turn.id));
|
||||
}
|
||||
match notification.turn.status {
|
||||
TurnStatus::Completed => {
|
||||
@@ -6497,11 +6595,23 @@ impl ChatWidget {
|
||||
}
|
||||
TurnStatus::Interrupted => {
|
||||
self.last_non_retry_error = None;
|
||||
self.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
if matches!(
|
||||
state_cleanup,
|
||||
TurnCompletionStateCleanup::PreservePendingMarkers
|
||||
) {
|
||||
self.finalize_turn_preserving_submission_state();
|
||||
self.request_redraw();
|
||||
self.maybe_send_next_queued_input();
|
||||
} else {
|
||||
self.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
}
|
||||
}
|
||||
TurnStatus::Failed => {
|
||||
if replay_kind.is_some() {
|
||||
self.last_non_retry_error = None;
|
||||
self.finalize_turn_preserving_submission_state();
|
||||
self.request_redraw();
|
||||
self.maybe_send_next_queued_input();
|
||||
return;
|
||||
}
|
||||
if let Some(error) = notification.turn.error {
|
||||
@@ -6825,8 +6935,8 @@ impl ChatWidget {
|
||||
..
|
||||
}) => {
|
||||
if !is_resume_initial_replay {
|
||||
self.pending_turn_start_after_submit = false;
|
||||
self.clear_standalone_user_shell_turn(Some(&turn_id));
|
||||
self.turn_submission_state
|
||||
.clear_for_finished_turn(Some(&turn_id));
|
||||
}
|
||||
self.on_task_complete(last_agent_message, from_replay);
|
||||
}
|
||||
@@ -6867,8 +6977,8 @@ impl ChatWidget {
|
||||
EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev),
|
||||
EventMsg::TurnAborted(ev) => {
|
||||
if !is_resume_initial_replay {
|
||||
self.pending_turn_start_after_submit = false;
|
||||
self.clear_standalone_user_shell_turn(ev.turn_id.as_deref());
|
||||
self.turn_submission_state
|
||||
.clear_for_finished_turn(ev.turn_id.as_deref());
|
||||
}
|
||||
match ev.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
|
||||
@@ -801,9 +801,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
task_running: true,
|
||||
agent_turn_running: true,
|
||||
pending_turn_start_after_submit: false,
|
||||
pending_standalone_user_shell_command: false,
|
||||
standalone_user_shell_turn_id: None,
|
||||
turn_submission_state: TurnSubmissionState::Idle,
|
||||
}));
|
||||
|
||||
assert!(chat.agent_turn_running);
|
||||
@@ -817,29 +815,6 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_thread_input_state_preserves_pending_standalone_shell_command() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||
composer: None,
|
||||
pending_steers: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
queued_user_messages: VecDeque::new(),
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
task_running: false,
|
||||
agent_turn_running: false,
|
||||
pending_turn_start_after_submit: true,
|
||||
pending_standalone_user_shell_command: true,
|
||||
standalone_user_shell_turn_id: None,
|
||||
}));
|
||||
|
||||
assert!(chat.pending_turn_start_after_submit);
|
||||
assert!(chat.pending_standalone_user_shell_command);
|
||||
assert!(chat.standalone_user_shell_turn_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alt_up_edits_most_recent_queued_message() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1028,73 +1028,7 @@ async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enter_during_standalone_user_shell_command_queues_follow_up() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("!sleep 10".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "sleep 10"),
|
||||
other => panic!("expected RunUserShellCommand op, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: Some(0),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("hi".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["hi"]);
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: Some(0),
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "hi".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued follow-up submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enter_before_standalone_user_shell_turn_started_queues_follow_up() {
|
||||
async fn enter_while_standalone_user_shell_command_is_pending_or_running_queues_follow_up() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
@@ -1108,15 +1042,15 @@ async fn enter_before_standalone_user_shell_turn_started_queues_follow_up() {
|
||||
}
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("hi".to_string(), Vec::new(), Vec::new());
|
||||
.set_composer_text("before start".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["hi"]);
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["before start"]);
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.maybe_send_next_queued_input();
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["hi"]);
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["before start"]);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_server_notification(
|
||||
@@ -1135,6 +1069,17 @@ async fn enter_before_standalone_user_shell_turn_started_queues_follow_up() {
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("while running".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["before start", "while running"]
|
||||
);
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
@@ -1155,106 +1100,13 @@ async fn enter_before_standalone_user_shell_turn_started_queues_follow_up() {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "hi".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued follow-up submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bang_after_user_turn_submit_before_turn_started_does_not_mark_standalone() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("start work".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::UserTurn { items, .. }) => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "start work".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected UserTurn op, got {other:?}"),
|
||||
}
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::AddToHistory { text }) => assert_eq!(text, "start work"),
|
||||
other => panic!("expected AddToHistory op, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("!sleep 10".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "sleep 10"),
|
||||
other => panic!("expected RunUserShellCommand op, got {other:?}"),
|
||||
}
|
||||
assert!(!chat.pending_standalone_user_shell_command);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bang_during_mcp_startup_marks_standalone_shell_command() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.mcp_startup_status = Some(HashMap::from([(
|
||||
"server".to_string(),
|
||||
McpStartupStatus::Starting,
|
||||
)]));
|
||||
chat.update_task_running_state();
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("!sleep 10".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "sleep 10"),
|
||||
other => panic!("expected RunUserShellCommand op, got {other:?}"),
|
||||
}
|
||||
assert!(chat.pending_turn_start_after_submit);
|
||||
assert!(chat.pending_standalone_user_shell_command);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_without_seen_shell_start_clears_pending_marker() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("follow up".to_string()));
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: Some(0),
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
assert!(!chat.pending_standalone_user_shell_command);
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "follow up".to_string(),
|
||||
text: "before start".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued follow-up submit, got {other:?}"),
|
||||
}
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["while running"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -254,9 +254,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
queued_user_messages: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
pending_turn_start_after_submit: false,
|
||||
pending_standalone_user_shell_command: false,
|
||||
standalone_user_shell_turn_id: None,
|
||||
turn_submission_state: TurnSubmissionState::Idle,
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
|
||||
suppress_session_configured_redraw: false,
|
||||
|
||||
@@ -800,41 +800,28 @@ async fn replayed_in_progress_turn_marks_task_running() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_in_progress_turn_consumes_pending_standalone_shell_marker() {
|
||||
async fn replayed_completed_user_shell_turn_clears_pending_standalone_shell_submission() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
chat.turn_submission_state = TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
preceding_turn_id: Some("baseline-turn".to_string()),
|
||||
};
|
||||
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
assert!(!chat.pending_standalone_user_shell_command);
|
||||
assert_eq!(
|
||||
chat.standalone_user_shell_turn_id.as_deref(),
|
||||
Some("shell-turn")
|
||||
);
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_completed_turn_preserves_pending_shell_submit_markers() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.pending_turn_start_after_submit = true;
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
id: "old-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
id: "old-shell-turn".to_string(),
|
||||
items: vec![AppServerThreadItem::CommandExecution {
|
||||
id: "old-cmd".to_string(),
|
||||
command: "echo hi".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
process_id: None,
|
||||
source: AppServerCommandExecutionSource::UserShell,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions: Vec::new(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
@@ -844,113 +831,50 @@ async fn replayed_completed_turn_preserves_pending_shell_submit_markers() {
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
|
||||
assert!(chat.pending_turn_start_after_submit);
|
||||
assert!(chat.pending_standalone_user_shell_command);
|
||||
assert!(chat.standalone_user_shell_turn_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_buffered_shell_turn_completion_clears_pending_shell_marker() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.pending_turn_start_after_submit = true;
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: Some(0),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
Some(ReplayKind::ThreadSnapshot),
|
||||
);
|
||||
|
||||
assert!(!chat.pending_turn_start_after_submit);
|
||||
assert!(!chat.pending_standalone_user_shell_command);
|
||||
assert_eq!(
|
||||
chat.standalone_user_shell_turn_id.as_deref(),
|
||||
Some("shell-turn")
|
||||
chat.turn_submission_state,
|
||||
TurnSubmissionState::AwaitingTurnStart {
|
||||
kind: TurnSubmissionKind::StandaloneUserShell,
|
||||
preceding_turn_id: Some("baseline-turn".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
chat.replay_thread_turns(
|
||||
vec![
|
||||
AppServerTurn {
|
||||
id: "baseline-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: Some(0),
|
||||
started_at: None,
|
||||
completed_at: Some(0),
|
||||
duration_ms: None,
|
||||
},
|
||||
AppServerTurn {
|
||||
id: "shell-turn".to_string(),
|
||||
items: vec![AppServerThreadItem::CommandExecution {
|
||||
id: "cmd-1".to_string(),
|
||||
command: "echo hi".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
process_id: None,
|
||||
source: AppServerCommandExecutionSource::UserShell,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions: Vec::new(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: Some(1),
|
||||
duration_ms: Some(1),
|
||||
duration_ms: None,
|
||||
},
|
||||
}),
|
||||
Some(ReplayKind::ThreadSnapshot),
|
||||
);
|
||||
|
||||
assert!(chat.standalone_user_shell_turn_id.is_none());
|
||||
assert!(!chat.pending_standalone_user_shell_command);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_non_retry_error_preserves_pending_shell_submit_markers() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.pending_turn_start_after_submit = true;
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
message: "permission denied".to_string(),
|
||||
codex_error_info: None,
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "old-turn".to_string(),
|
||||
}),
|
||||
Some(ReplayKind::ThreadSnapshot),
|
||||
);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.pending_turn_start_after_submit);
|
||||
assert!(chat.pending_standalone_user_shell_command);
|
||||
assert!(chat.standalone_user_shell_turn_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_failed_completed_turn_preserves_pending_shell_submit_markers() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.pending_turn_start_after_submit = true;
|
||||
chat.pending_standalone_user_shell_command = true;
|
||||
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
id: "old-turn".to_string(),
|
||||
items: Vec::new(),
|
||||
status: AppServerTurnStatus::Failed,
|
||||
error: Some(AppServerTurnError {
|
||||
message: "permission denied".to_string(),
|
||||
codex_error_info: None,
|
||||
additional_details: None,
|
||||
}),
|
||||
started_at: None,
|
||||
completed_at: Some(0),
|
||||
duration_ms: None,
|
||||
}],
|
||||
],
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.pending_turn_start_after_submit);
|
||||
assert!(chat.pending_standalone_user_shell_command);
|
||||
assert!(chat.standalone_user_shell_turn_id.is_none());
|
||||
assert_eq!(chat.turn_submission_state, TurnSubmissionState::Idle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -365,9 +365,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
task_running: false,
|
||||
agent_turn_running: false,
|
||||
pending_turn_start_after_submit: false,
|
||||
pending_standalone_user_shell_command: false,
|
||||
standalone_user_shell_turn_id: None,
|
||||
turn_submission_state: TurnSubmissionState::Idle,
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user