mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
tui: submit pending nudges after message boundaries
This commit is contained in:
@@ -770,7 +770,12 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Update the queued messages preview shown above the composer.
|
||||
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
|
||||
pub(crate) fn set_queued_user_messages(
|
||||
&mut self,
|
||||
queued: Vec<String>,
|
||||
pending_nudges: Vec<String>,
|
||||
) {
|
||||
self.queued_user_messages.pending_nudges = pending_nudges;
|
||||
self.queued_user_messages.messages = queued;
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -1014,7 +1019,8 @@ impl BottomPane {
|
||||
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
|
||||
}
|
||||
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty()
|
||||
|| !self.queued_user_messages.pending_nudges.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
let has_inline_previews = has_pending_thread_approvals || has_queued_messages;
|
||||
@@ -1401,7 +1407,7 @@ mod tests {
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
@@ -1428,7 +1434,7 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.hide_status_indicator();
|
||||
|
||||
let width = 48;
|
||||
@@ -1456,7 +1462,7 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
|
||||
@@ -10,13 +10,15 @@ use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
|
||||
/// Widget that displays a list of user messages queued while a turn is in progress.
|
||||
/// Widget that displays pending nudges plus user messages queued while a turn is in progress.
|
||||
///
|
||||
/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the
|
||||
/// user how to pop the most recent queued message back into the composer.
|
||||
/// Because some terminals intercept certain modifier-key combinations, the
|
||||
/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
/// The widget shows pending nudges first, then queued user messages. It only
|
||||
/// shows the edit hint at the bottom (e.g. "⌥ + ↑ edit") when there are actual
|
||||
/// queued user messages to pop back into the composer. Because some terminals
|
||||
/// intercept certain modifier-key combinations, the displayed binding is
|
||||
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
pub(crate) struct QueuedUserMessages {
|
||||
pub pending_nudges: Vec<String>,
|
||||
pub messages: Vec<String>,
|
||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||
/// be overridden for terminals where that chord is unavailable.
|
||||
@@ -26,6 +28,7 @@ pub(crate) struct QueuedUserMessages {
|
||||
impl QueuedUserMessages {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
pending_nudges: Vec::new(),
|
||||
messages: Vec::new(),
|
||||
edit_binding: key_hint::alt(KeyCode::Up),
|
||||
}
|
||||
@@ -39,12 +42,30 @@ impl QueuedUserMessages {
|
||||
}
|
||||
|
||||
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
||||
if self.messages.is_empty() || width < 4 {
|
||||
if (self.pending_nudges.is_empty() && self.messages.is_empty()) || width < 4 {
|
||||
return Box::new(());
|
||||
}
|
||||
|
||||
let mut lines = vec![];
|
||||
|
||||
for nudge in &self.pending_nudges {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
nudge
|
||||
.lines()
|
||||
.map(|line| format!("pending nudge: {line}").dim()),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(" ! ".dim()))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
);
|
||||
let len = wrapped.len();
|
||||
for line in wrapped.into_iter().take(3) {
|
||||
lines.push(line);
|
||||
}
|
||||
if len > 3 {
|
||||
lines.push(Line::from(" …".dim()));
|
||||
}
|
||||
}
|
||||
|
||||
for message in &self.messages {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
message.lines().map(|line| line.dim().italic()),
|
||||
@@ -61,14 +82,16 @@ impl QueuedUserMessages {
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
self.edit_binding.into(),
|
||||
" edit".into(),
|
||||
])
|
||||
.dim(),
|
||||
);
|
||||
if !self.messages.is_empty() {
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
self.edit_binding.into(),
|
||||
" edit".into(),
|
||||
])
|
||||
.dim(),
|
||||
);
|
||||
}
|
||||
|
||||
Paragraph::new(lines).into()
|
||||
}
|
||||
@@ -202,4 +225,33 @@ mod tests {
|
||||
"expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_one_pending_nudge() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.pending_nudges.push("Implement the plan.".to_string());
|
||||
let width = 48;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!("render_one_pending_nudge", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pending_nudges_above_queued_messages() {
|
||||
let mut queue = QueuedUserMessages::new();
|
||||
queue.pending_nudges.push("Implement the plan.".to_string());
|
||||
queue
|
||||
.pending_nudges
|
||||
.push("Summarize the diff after the tool call.".to_string());
|
||||
queue.messages.push("Queued follow-up question".to_string());
|
||||
let width = 52;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!(
|
||||
"render_pending_nudges_above_queued_messages",
|
||||
format!("{buf:?}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 48, height: 1 },
|
||||
content: [
|
||||
" ! pending nudge: Implement the plan. ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 38, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/queued_user_messages.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 52, height: 5 },
|
||||
content: [
|
||||
" ! pending nudge: Implement the plan. ",
|
||||
" ! pending nudge: Summarize the diff after the tool",
|
||||
" call. ",
|
||||
" ↳ Queued follow-up question ",
|
||||
" ⌥ + ↑ edit ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 38, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 9, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 29, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -617,6 +617,19 @@ pub(crate) struct ChatWidget {
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Nudges deferred while a turn is in progress.
|
||||
//
|
||||
// Once the current streamed assistant message is done rendering, we
|
||||
// interrupt the running turn (if needed) and then submit the pending
|
||||
// nudges as fresh follow-up input. `on_task_complete` still drains this
|
||||
// queue when the turn finishes naturally before an interrupt is needed.
|
||||
pending_nudges: VecDeque<UserMessage>,
|
||||
// True after we have requested an automatic interrupt so a pending nudge
|
||||
// can be submitted as a fresh turn.
|
||||
pending_nudge_interrupt_requested: bool,
|
||||
// Number of legacy `EventMsg::AgentMessage` events to ignore because the
|
||||
// corresponding `item.completed` already finalized the streamed message.
|
||||
pending_suppressed_legacy_agent_messages: usize,
|
||||
/// Terminal-appropriate keybinding for popping the most-recently queued
|
||||
/// message back into the composer. Determined once at construction time via
|
||||
/// [`queued_message_edit_binding_for_terminal`] and propagated to
|
||||
@@ -1289,6 +1302,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_agent_message(&mut self, message: String) {
|
||||
if self.pending_suppressed_legacy_agent_messages > 0 {
|
||||
self.pending_suppressed_legacy_agent_messages -= 1;
|
||||
return;
|
||||
}
|
||||
// If we have a stream_controller, then the final agent message is redundant and will be a
|
||||
// duplicate of what has already been streamed.
|
||||
if self.stream_controller.is_none() && !message.is_empty() {
|
||||
@@ -1481,8 +1498,12 @@ impl ChatWidget {
|
||||
self.unified_exec_wait_streak = None;
|
||||
self.request_redraw();
|
||||
|
||||
if !from_replay && self.queued_user_messages.is_empty() {
|
||||
self.maybe_prompt_plan_implementation();
|
||||
if !from_replay {
|
||||
self.pending_nudge_interrupt_requested = false;
|
||||
let drained_pending_nudges = self.maybe_send_pending_nudges();
|
||||
if !drained_pending_nudges && self.queued_user_messages.is_empty() {
|
||||
self.maybe_prompt_plan_implementation();
|
||||
}
|
||||
}
|
||||
// Keep this flag for replayed completion events so a subsequent live TurnComplete can
|
||||
// still show the prompt once after thread switch replay.
|
||||
@@ -1769,6 +1790,8 @@ impl ChatWidget {
|
||||
|
||||
self.add_to_history(history_cell::new_warning_event(message));
|
||||
self.request_redraw();
|
||||
self.pending_nudge_interrupt_requested = false;
|
||||
self.maybe_send_pending_nudges();
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
|
||||
@@ -1777,6 +1800,8 @@ impl ChatWidget {
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.request_redraw();
|
||||
|
||||
self.pending_nudge_interrupt_requested = false;
|
||||
self.maybe_send_pending_nudges();
|
||||
// After an error ends the turn, try sending the next queued input.
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
@@ -1858,19 +1883,23 @@ impl ChatWidget {
|
||||
/// When there are queued user messages, restore them into the composer
|
||||
/// separated by newlines rather than auto‑submitting the next one.
|
||||
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||||
let interrupted_for_pending_nudge =
|
||||
std::mem::take(&mut self.pending_nudge_interrupt_requested);
|
||||
// Finalize, log a gentle prompt, and clear running state.
|
||||
self.finalize_turn();
|
||||
if reason == TurnAbortReason::Interrupted {
|
||||
self.clear_unified_exec_processes();
|
||||
}
|
||||
|
||||
if reason != TurnAbortReason::ReviewEnded {
|
||||
if !interrupted_for_pending_nudge && reason != TurnAbortReason::ReviewEnded {
|
||||
self.add_to_history(history_cell::new_error_event(
|
||||
"Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(combined) = self.drain_queued_messages_for_restore() {
|
||||
if interrupted_for_pending_nudge {
|
||||
self.maybe_send_pending_nudges();
|
||||
} else if let Some(combined) = self.drain_queued_messages_for_restore() {
|
||||
self.restore_user_message_to_composer(combined);
|
||||
self.refresh_queued_user_messages();
|
||||
}
|
||||
@@ -2348,6 +2377,11 @@ impl ChatWidget {
|
||||
/// returns once stream queues are idle. Final-answer completion (or absent
|
||||
/// phase for legacy models) clears the flag to preserve historical behavior.
|
||||
fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) {
|
||||
if self.stream_controller.is_some() {
|
||||
self.pending_suppressed_legacy_agent_messages += item.content.len();
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.handle_stream_finished();
|
||||
}
|
||||
self.pending_status_indicator_restore = match item.phase {
|
||||
// Models that don't support preambles only output AgentMessageItems on turn completion.
|
||||
Some(MessagePhase::FinalAnswer) | None => false,
|
||||
@@ -2431,6 +2465,9 @@ impl ChatWidget {
|
||||
}
|
||||
// A completed stream indicates non-exec content was just inserted.
|
||||
self.flush_interrupt_queue();
|
||||
if self.agent_turn_running {
|
||||
self.maybe_send_pending_nudges();
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -2913,6 +2950,9 @@ impl ChatWidget {
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_nudges: VecDeque::new(),
|
||||
pending_nudge_interrupt_requested: false,
|
||||
pending_suppressed_legacy_agent_messages: 0,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
startup_tooltip_override,
|
||||
@@ -3096,6 +3136,9 @@ impl ChatWidget {
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_nudges: VecDeque::new(),
|
||||
pending_nudge_interrupt_requested: false,
|
||||
pending_suppressed_legacy_agent_messages: 0,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
startup_tooltip_override,
|
||||
@@ -3260,6 +3303,9 @@ impl ChatWidget {
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_nudges: VecDeque::new(),
|
||||
pending_nudge_interrupt_requested: false,
|
||||
pending_suppressed_legacy_agent_messages: 0,
|
||||
queued_message_edit_binding,
|
||||
show_welcome_banner: false,
|
||||
startup_tooltip_override: None,
|
||||
@@ -3429,15 +3475,13 @@ impl ChatWidget {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Submissions during active final-answer streaming can race with turn
|
||||
// completion and strand the UI in a running state. Queue those inputs instead
|
||||
// of injecting immediately; `on_task_complete()` drains this FIFO via
|
||||
// `maybe_send_next_queued_input()`, so no typed prompt is dropped.
|
||||
let should_submit_now = self.is_session_configured()
|
||||
&& !self.is_plan_streaming_in_tui()
|
||||
&& self.stream_controller.is_none();
|
||||
if should_submit_now {
|
||||
// Submitted is emitted when user submits.
|
||||
let should_defer_as_pending_nudge = self.is_session_configured()
|
||||
&& (self.is_plan_streaming_in_tui() || self.stream_controller.is_some());
|
||||
if should_defer_as_pending_nudge {
|
||||
self.pending_nudges.push_back(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
} else if self.is_session_configured() {
|
||||
// Submitted is only emitted when steer is enabled.
|
||||
// Reset any reasoning header only when we are actually submitting a turn.
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
@@ -4565,6 +4609,9 @@ impl ChatWidget {
|
||||
let item = event.item;
|
||||
if let codex_protocol::items::TurnItem::Plan(plan_item) = &item {
|
||||
self.on_plan_item_completed(plan_item.text.clone());
|
||||
if !from_replay {
|
||||
self.maybe_send_pending_nudges();
|
||||
}
|
||||
}
|
||||
if let codex_protocol::items::TurnItem::AgentMessage(item) = item {
|
||||
self.on_agent_message_item_completed(item);
|
||||
@@ -4716,6 +4763,28 @@ impl ChatWidget {
|
||||
self.refresh_queued_user_messages();
|
||||
}
|
||||
|
||||
fn maybe_send_pending_nudges(&mut self) -> bool {
|
||||
if self.stream_controller.is_some() || self.is_plan_streaming_in_tui() {
|
||||
return false;
|
||||
}
|
||||
if self.pending_nudges.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.agent_turn_running {
|
||||
if !self.pending_nudge_interrupt_requested {
|
||||
self.pending_nudge_interrupt_requested = true;
|
||||
self.submit_op(Op::Interrupt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.pending_nudge_interrupt_requested = false;
|
||||
while let Some(pending_nudge) = self.pending_nudges.pop_front() {
|
||||
self.submit_user_message(pending_nudge);
|
||||
}
|
||||
self.refresh_queued_user_messages();
|
||||
true
|
||||
}
|
||||
|
||||
/// Rebuild and update the queued user messages from the current queue.
|
||||
fn refresh_queued_user_messages(&mut self) {
|
||||
let messages: Vec<String> = self
|
||||
@@ -4723,7 +4792,13 @@ impl ChatWidget {
|
||||
.iter()
|
||||
.map(|m| m.text.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_queued_user_messages(messages);
|
||||
let pending_nudges: Vec<String> = self
|
||||
.pending_nudges
|
||||
.iter()
|
||||
.map(|nudge| nudge.text.clone())
|
||||
.collect();
|
||||
self.bottom_pane
|
||||
.set_queued_user_messages(messages, pending_nudges);
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
|
||||
@@ -7539,6 +7614,13 @@ impl ChatWidget {
|
||||
{
|
||||
collaboration_mode.reasoning_effort = Some(Some(effort));
|
||||
}
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
if self.agent_turn_running
|
||||
&& self.active_collaboration_mask.as_ref() != Some(&collaboration_mode)
|
||||
{
|
||||
@@ -7548,15 +7630,7 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
self.set_collaboration_mask(collaboration_mode);
|
||||
let should_queue = self.is_plan_streaming_in_tui();
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
if should_queue {
|
||||
if self.is_plan_streaming_in_tui() {
|
||||
self.queue_user_message(user_message);
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
|
||||
@@ -1708,6 +1708,9 @@ async fn make_chatwidget_manual(
|
||||
show_welcome_banner: true,
|
||||
startup_tooltip_override: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
pending_nudges: VecDeque::new(),
|
||||
pending_nudge_interrupt_requested: false,
|
||||
pending_suppressed_legacy_agent_messages: 0,
|
||||
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
@@ -1755,6 +1758,17 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt) => return,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected an interrupt op but queue was empty"),
|
||||
Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
@@ -3447,7 +3461,7 @@ async fn unified_exec_begin_restores_working_status_snapshot() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enter_queues_while_plan_stream_is_active() {
|
||||
async fn steer_enter_uses_pending_nudges_while_plan_stream_is_active() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
@@ -3463,16 +3477,45 @@ async fn enter_queues_while_plan_stream_is_active() {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(chat.pending_nudges.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.pending_nudges.front().unwrap().text,
|
||||
"queued submission"
|
||||
);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "item-plan".into(),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::Plan(PlanItem {
|
||||
id: "plan-1".to_string(),
|
||||
text: "- Step 1".to_string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
assert!(chat.pending_nudges.is_empty());
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "queued submission".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn after plan stream completion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_enter_queues_while_final_answer_stream_is_active() {
|
||||
async fn steer_enter_uses_pending_nudges_while_final_answer_stream_is_active() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
@@ -3487,20 +3530,29 @@ async fn steer_enter_queues_while_final_answer_stream_is_active() {
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(chat.pending_nudges.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.pending_nudges.front().unwrap().text,
|
||||
"queued while streaming"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
|
||||
// Once final output ends, the queued input must be submitted automatically.
|
||||
chat.on_task_complete(None, false);
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-commentary",
|
||||
"Final answer line\n",
|
||||
Some(MessagePhase::Commentary),
|
||||
);
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
next_interrupt_op(&mut op_rx);
|
||||
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
assert!(chat.pending_nudges.is_empty());
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { .. } => {}
|
||||
other => panic!("expected Op::UserTurn after stream completion, got {other:?}"),
|
||||
other => panic!("expected Op::UserTurn after message completion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3520,19 +3572,22 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
|
||||
.set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 2);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
"first follow-up"
|
||||
);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.back().unwrap().text,
|
||||
"second follow-up"
|
||||
);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(chat.pending_nudges.len(), 2);
|
||||
assert_eq!(chat.pending_nudges.front().unwrap().text, "first follow-up");
|
||||
assert_eq!(chat.pending_nudges.back().unwrap().text, "second follow-up");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
|
||||
// Completion must recover by submitting the oldest queued prompt first.
|
||||
chat.on_task_complete(None, false);
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-commentary",
|
||||
"Final answer line\n",
|
||||
Some(MessagePhase::Commentary),
|
||||
);
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
let first_items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
@@ -3545,16 +3600,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
"second follow-up"
|
||||
);
|
||||
|
||||
// A subsequent turn lifecycle should continue draining remaining queued prompts, proving
|
||||
// the widget did not enter a permanently stuck state.
|
||||
chat.on_task_started();
|
||||
chat.on_task_complete(None, false);
|
||||
assert!(chat.pending_nudges.is_empty());
|
||||
|
||||
let second_items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
@@ -3567,7 +3613,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert!(chat.pending_nudges.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user