core: emit leftover user-message lifecycle

This commit is contained in:
Charles Cunningham
2026-03-02 16:15:27 -08:00
parent 625ed448f3
commit 4a03589330
4 changed files with 88 additions and 43 deletions
+70 -2
View File
@@ -6554,6 +6554,7 @@ mod tests {
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnCompleteEvent;
use crate::protocol::UserMessageEvent;
use crate::rollout::policy::EventPersistenceMode;
use crate::rollout::recorder::RolloutRecorder;
@@ -9265,8 +9266,8 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_persists_leftover_pending_input() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
@@ -9281,6 +9282,8 @@ mod tests {
)
.await;
while rx.try_recv().is_ok() {}
sess.inject_response_items(vec![ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
@@ -9306,6 +9309,71 @@ mod tests {
history.raw_items().iter().any(|item| item == &expected),
"expected pending input to be persisted into history on turn completion"
);
let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("expected raw response item event")
.expect("channel open");
assert!(matches!(first.msg, EventMsg::RawResponseItem(_)));
let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("expected item started event")
.expect("channel open");
assert!(matches!(
second.msg,
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
..
}) if content == vec![UserInput::Text {
text: "late pending input".to_string(),
text_elements: Vec::new(),
}]
));
let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("expected item completed event")
.expect("channel open");
assert!(matches!(
third.msg,
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
..
}) if content == vec![UserInput::Text {
text: "late pending input".to_string(),
text_elements: Vec::new(),
}]
));
let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("expected legacy user message event")
.expect("channel open");
assert!(matches!(
fourth.msg,
EventMsg::UserMessage(UserMessageEvent {
message,
images,
text_elements,
local_images,
}) if message == "late pending input"
&& images == Some(Vec::new())
&& text_elements.is_empty()
&& local_images.is_empty()
));
let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("expected turn complete event")
.expect("channel open");
assert!(matches!(
fifth.msg,
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
}) if turn_id == tc.sub_id
));
}
#[tokio::test]
+18 -2
View File
@@ -22,6 +22,7 @@ use crate::AuthManager;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
use crate::event_mapping::parse_turn_item;
use crate::models_manager::manager::ModelsManager;
use crate::protocol::EventMsg;
use crate::protocol::TurnAbortReason;
@@ -30,6 +31,7 @@ use crate::protocol::TurnCompleteEvent;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -213,8 +215,22 @@ impl Session {
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
self.record_conversation_items(turn_context.as_ref(), &pending_response_items)
.await;
for response_item in pending_response_items {
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
self.record_user_prompt_and_emit_turn_item(
turn_context.as_ref(),
&user_message.content,
response_item,
)
.await;
} else {
self.record_conversation_items(
turn_context.as_ref(),
std::slice::from_ref(&response_item),
)
.await;
}
}
}
let event = EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_context.sub_id.clone(),
-15
View File
@@ -1510,21 +1510,6 @@ impl ChatWidget {
self.request_redraw();
let had_pending_steers = !self.pending_steers.is_empty();
while let Some(pending_steer) = self.pending_steers.pop_front() {
self.last_rendered_user_message_event = Some(pending_steer.clone());
if !pending_steer.message.trim().is_empty()
|| !pending_steer.text_elements.is_empty()
|| !pending_steer.remote_image_urls.is_empty()
{
self.add_to_history(history_cell::new_user_prompt(
pending_steer.message,
pending_steer.text_elements,
pending_steer.local_images,
pending_steer.remote_image_urls,
));
}
self.needs_final_message_separator = false;
}
self.refresh_pending_input_preview();
if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers {
-24
View File
@@ -3973,30 +3973,6 @@ queued draft"
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn task_complete_renders_unacknowledged_pending_steer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.bottom_pane
.set_composer_text("late steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { .. } => {}
other => panic!("expected Op::UserTurn, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
chat.on_task_complete(None, false);
assert!(chat.pending_steers.is_empty());
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("late steer"));
}
#[tokio::test]
async fn enter_submits_when_plan_stream_is_not_active() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;