tui: submit pending nudges after message boundaries

This commit is contained in:
Charles Cunningham
2026-03-02 16:13:51 -08:00
parent b20b6aa46f
commit dbb69dd163
6 changed files with 293 additions and 74 deletions
+11 -5
View File
@@ -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:?}")
);
}
}
@@ -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,
]
}
@@ -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,
]
}
+97 -23
View File
@@ -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);
+78 -32
View File
@@ -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]