Acknowledge pending TUI steers by submission ID (#42451)

## Why

Completion receipts for identical user messages could acknowledge the wrong
pending steer. When the receipt arrived while another thread was displayed,
the stale pending state could later be restored and resubmitted.

## What changed

- Assign each TUI submission a client message ID and pass it through turn start
  and steer requests.
- Preserve pending steer IDs across thread input snapshots and match completion
  receipts by ID, while retaining content-based matching for older app servers
  that do not echo IDs.
- Acknowledge matching receipts for inactive threads before their notifications
  can be discarded, including threads stored in the agents overview.

## Testing

Added coverage for identical steer receipts and for switching within and
between tasks before interrupting an active turn.

GitOrigin-RevId: 28bd80a735ff86cd7bf836f24b448e7499f0a902
This commit is contained in:
Eric Traut
2026-09-03 05:15:20 +00:00
committed by copyberry
parent 36984da442
commit 8b8ee28a9b
14 changed files with 250 additions and 97 deletions
@@ -116,6 +116,7 @@ mod tests {
.rejected_steers_queue
.push_back(UserMessage::from("rejected"));
state.pending_steers.push_back(PendingSteer {
client_id: "test-submission".to_string(),
user_message: UserMessage::from("pending"),
history_record: UserMessageHistoryRecord::UserMessageText,
compare_key: crate::chatwidget::user_messages::PendingSteerCompareKey {
+6 -48
View File
@@ -1,7 +1,6 @@
//! Input queue restore and thread-input snapshot behavior for `ChatWidget`.
use std::collections::HashSet;
use std::collections::VecDeque;
use crate::bottom_pane::ComposerDraftSnapshot;
@@ -446,24 +445,7 @@ impl ChatWidget {
Some(ThreadInputState {
composer: composer.has_content().then_some(composer),
safety_buffering_prompt: self.safety_buffering_prompt.clone(),
pending_steers: self
.input_queue
.pending_steers
.iter()
.map(|pending| pending.user_message.clone())
.collect(),
pending_steer_history_records: self
.input_queue
.pending_steers
.iter()
.map(|pending| pending.history_record.clone())
.collect(),
pending_steer_compare_keys: self
.input_queue
.pending_steers
.iter()
.map(|pending| pending.compare_key.clone())
.collect(),
pending_steers: self.input_queue.pending_steers.clone(),
rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(),
rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(),
queued_user_messages: self.input_queue.queued_user_messages.clone(),
@@ -507,42 +489,18 @@ impl ChatWidget {
self.update_collaboration_mode_indicator();
self.refresh_model_dependent_surfaces();
self.restore_composer_state(input_state.composer.unwrap_or_default());
let mut pending_steer_history_records = input_state.pending_steer_history_records;
pending_steer_history_records.resize(
input_state.pending_steers.len(),
UserMessageHistoryRecord::UserMessageText,
);
let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys;
let pending_steers = input_state.pending_steers;
let mut queued_user_messages = input_state.queued_user_messages;
let mut queued_user_message_history_records =
input_state.queued_user_message_history_records;
if preserve_in_flight_turn {
self.input_queue.pending_steers = pending_steers
.into_iter()
.zip(pending_steer_history_records)
.map(|(user_message, history_record)| PendingSteer {
compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| {
PendingSteerCompareKey {
message: user_message.text.clone(),
image_count: user_message.local_images.len()
+ user_message.remote_image_urls.len(),
}
}),
history_record,
user_message,
})
.collect();
self.input_queue.pending_steers = pending_steers;
} else {
self.input_queue.pending_steers.clear();
let mut safety_retry_follow_ups = pending_steers
.into_iter()
.map(QueuedUserMessage::from)
.collect::<VecDeque<_>>();
safety_retry_follow_ups.append(&mut queued_user_messages);
queued_user_messages = safety_retry_follow_ups;
pending_steer_history_records.append(&mut queued_user_message_history_records);
queued_user_message_history_records = pending_steer_history_records;
for pending in pending_steers.into_iter().rev() {
queued_user_messages.push_front(pending.user_message.into());
queued_user_message_history_records.push_front(pending.history_record);
}
}
self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue;
self.input_queue.rejected_steer_history_records =
@@ -347,7 +347,9 @@ impl ChatWidget {
} else {
None
};
let client_user_message_id = uuid::Uuid::new_v4().to_string();
let pending_steer = (!render_in_history).then(|| PendingSteer {
client_id: client_user_message_id.clone(),
user_message: UserMessage {
text: text.clone(),
local_images: local_images.clone(),
@@ -366,6 +368,7 @@ impl ChatWidget {
let service_tier = self.service_tier_update_for_core();
let active_permission_profile = self.config.permissions.active_permission_profile();
let op = AppCommand::user_turn(
client_user_message_id,
items,
self.config.cwd.to_path_buf(),
AskForApproval::from(self.config.permissions.approval_policy.value()),
+4 -2
View File
@@ -114,8 +114,10 @@ impl ChatWidget {
let from_replay = render_source.is_replay();
let replay_kind = render_source.replay_kind();
match item {
ThreadItem::UserMessage { content, .. } => {
self.on_committed_user_message(&content, from_replay);
ThreadItem::UserMessage {
content, client_id, ..
} => {
self.on_committed_user_message(&content, client_id.as_deref(), from_replay);
}
ThreadItem::AgentMessage {
id,
@@ -1761,9 +1761,10 @@ async fn restore_thread_input_state_applies_running_state_policy() {
..Default::default()
}),
safety_buffering_prompt: Some(UserMessage::from("buffered prompt")),
pending_steers: VecDeque::from([UserMessage::from("submitted to the interrupted turn")]),
pending_steer_history_records: VecDeque::from([pending_history.clone()]),
pending_steer_compare_keys: VecDeque::new(),
pending_steers: VecDeque::from([PendingSteer {
history_record: pending_history.clone(),
..pending_steer("submitted to the interrupted turn")
}]),
rejected_steers_queue: VecDeque::new(),
rejected_steer_history_records: VecDeque::new(),
queued_user_messages: VecDeque::from([UserMessage::from("already queued").into()]),
@@ -940,6 +940,7 @@ pub(super) fn complete_assistant_message(
pub(super) fn pending_steer(text: &str) -> PendingSteer {
PendingSteer {
client_id: "test-submission".to_string(),
user_message: UserMessage::from(text),
history_record: UserMessageHistoryRecord::UserMessageText,
compare_key: PendingSteerCompareKey {
@@ -360,14 +360,15 @@ async fn review_restores_context_window_indicator() {
#[tokio::test]
async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut pending_steers = VecDeque::new();
pending_steers.push_back(UserMessage::from("pending steer"));
let expected_compare_key = PendingSteerCompareKey {
message: "hidden IDE context\npending steer".to_string(),
image_count: 0,
};
let mut pending_steer_compare_keys = VecDeque::new();
pending_steer_compare_keys.push_back(expected_compare_key.clone());
let expected_pending = PendingSteer {
client_id: "saved-submission".to_string(),
compare_key: expected_compare_key,
..pending_steer("pending steer")
};
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
@@ -377,9 +378,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
Some(ThreadInputState {
composer: None,
safety_buffering_prompt: None,
pending_steers,
pending_steer_history_records: VecDeque::new(),
pending_steer_compare_keys,
pending_steers: VecDeque::from([expected_pending.clone()]),
rejected_steers_queue,
rejected_steer_history_records: VecDeque::new(),
queued_user_messages,
@@ -401,22 +400,38 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.queued_user_message_texts(),
vec!["already rejected", "queued draft"]
);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"pending steer"
);
assert_eq!(
chat.input_queue.pending_steers.front().unwrap().compare_key,
expected_compare_key
chat.input_queue.pending_steers,
VecDeque::from([expected_pending])
);
}
#[tokio::test]
async fn identical_steer_receipts_only_acknowledge_the_matching_submission() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let pending = pending_steer("continue");
chat.input_queue.pending_steers.push_back(pending.clone());
for (client_id, expected) in [
("older-submission", VecDeque::from([pending.clone()])),
(pending.client_id.as_str(), VecDeque::new()),
] {
chat.handle_thread_item(
AppServerThreadItem::UserMessage {
id: client_id.to_string(),
client_id: Some(client_id.to_string()),
content: vec![UserInput::Text {
text: "continue".to_string(),
text_elements: Vec::new(),
}],
},
"turn-1".to_string(),
ThreadItemRenderSource::Live,
);
assert_eq!(chat.input_queue.pending_steers, expected);
}
}
#[tokio::test]
async fn steer_enter_queues_while_plan_stream_is_active() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+6 -9
View File
@@ -124,9 +124,7 @@ impl ThreadComposerState {
pub(crate) struct ThreadInputState {
pub(super) composer: Option<ThreadComposerState>,
pub(super) safety_buffering_prompt: Option<UserMessage>,
pub(super) pending_steers: VecDeque<UserMessage>,
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
pub(crate) pending_steers: VecDeque<PendingSteer>,
pub(super) rejected_steers_queue: VecDeque<UserMessage>,
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
@@ -171,8 +169,10 @@ impl From<&str> for UserMessage {
}
}
#[derive(Debug)]
pub(super) struct PendingSteer {
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PendingSteer {
/// Preserved across request retries and thread switches until this submission commits.
pub(crate) client_id: String,
pub(super) user_message: UserMessage,
pub(super) history_record: UserMessageHistoryRecord,
pub(super) compare_key: PendingSteerCompareKey,
@@ -696,10 +696,7 @@ impl ChatWidget {
}
}
/// Build the compare key for a submitted pending steer without invoking the
/// expensive request-serialization path. Pending steers only need to match the
/// committed app-server `UserMessage` item emitted after input drains, which
/// preserves flattened text and total image count.
/// Build the legacy content key for app servers that do not echo submission IDs.
pub(super) fn pending_steer_compare_key_from_items(
items: &[UserInput],
) -> PendingSteerCompareKey {