From 99d66aa1c5f8394729a97a6eea91880fa420352b Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Fri, 4 Sep 2026 13:40:06 +0000 Subject: [PATCH] Preserve acceptance order in retained thread context (#42770) ## Why Queued prompts and `request_user_input` answers can be persisted in a different order from when the host accepted them. Using persistence order can therefore reorder retained instructions during replay or apply the wrong rollback boundary. ## What changed - Reserve an acceptance sequence for user prompts and elicitation responses when retained thread context is enabled. - Persist that sequence with user-message metadata and verified answers, and use it when ordering, bounding, replaying, and rolling back retained context. - Keep events without acceptance metadata compatible with legacy recording order. ## Testing - Cover delayed queued-input recording, rejected prompts, checkpoint replay, compaction, rollback migration, and legacy retained-context behavior. GitOrigin-RevId: 42d24465cc2033bc51c0c2da79d3c0884a8ad98a --- .../app-server-exports-stable.json.zst | Bin 148006 -> 148081 bytes codex-rs/core/src/context_manager/history.rs | 15 +- .../core/src/context_manager/history_tests.rs | 14 +- codex-rs/core/src/hook_runtime.rs | 7 +- codex-rs/core/src/mcp_skill_dependencies.rs | 2 +- codex-rs/core/src/mcp_tool_call.rs | 2 +- codex-rs/core/src/session/input_queue.rs | 4 + codex-rs/core/src/session/mod.rs | 36 +++- codex-rs/core/src/session/retained_context.rs | 11 +- codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 8 +- codex-rs/core/src/session/tests.rs | 11 ++ codex-rs/core/src/session/turn_input.rs | 19 +- codex-rs/core/src/session/turn_input_tests.rs | 2 + codex-rs/core/src/state/mod.rs | 1 + codex-rs/core/src/state/turn.rs | 17 +- .../src/tools/handlers/request_user_input.rs | 17 +- .../tests/suite/guardian_retained_context.rs | 68 +++++--- codex-rs/core/tests/suite/hooks.rs | 38 +++- codex-rs/history/src/lib.rs | 4 + codex-rs/history/src/retained_context.rs | 79 +++++++-- .../history/src/retained_context_tests.rs | 163 ++++++++++++++---- .../local/rollout_migration/rollback_plan.rs | 64 +++++-- .../src/local/rollout_migration_tests.rs | 58 ++++++- 24 files changed, 489 insertions(+), 152 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 3b9f9d95f1f3cea4622fa59792808c68e4fee012..bd13ac46e821de69e1e1a43d5d06b8b66792098d 100644 GIT binary patch delta 19180 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 A?f?J) diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 19ec09fab6..ab8cf48895 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -187,6 +187,10 @@ impl ContextManager { self.retain_user_messages = true; } + pub(crate) fn reserve_input_order(&mut self) -> u64 { + Arc::make_mut(&mut self.retained_context).reserve_order() + } + pub(crate) fn record_retained_context(&mut self, event: &RetainedContextEvent) -> bool { if !Arc::make_mut(&mut self.retained_context).record(event) { return false; @@ -359,6 +363,7 @@ impl ContextManager { text, complete, }, + metadata.and_then(|metadata| metadata.user_input_order), ); } else { Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete(); @@ -536,6 +541,10 @@ impl ContextManager { let first_removed_message_id = snapshot[cut_idx] .id() .map(codex_protocol::ResponseItemId::as_str); + let acceptance_order = snapshot[cut_idx] + .metadata + .as_ref() + .and_then(|metadata| metadata.user_input_order); let mut review_history = self.review_history.take(); if let Some(history) = &mut review_history { history.truncate_before(&snapshot[cut_idx].item); @@ -577,7 +586,11 @@ impl ContextManager { .filter_map(|item| item.turn_id()) .collect::>(); if self.retain_user_messages { - Arc::make_mut(&mut retained_context).rollback(&removed_turns, first_removed_message_id); + Arc::make_mut(&mut retained_context).rollback( + &removed_turns, + first_removed_message_id, + acceptance_order, + ); } else { Arc::make_mut(&mut retained_context).retain_answers(|answer| { // Legacy answers follow their original call, not later steers in the same turn. diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index d23e8ef8ad..98ad19ef8e 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1252,8 +1252,8 @@ fn drop_last_n_user_turns_preserves_prefix() { ); // With no remaining instruction boundary, rollback must not revoke facts from a // prior checkpoint merely because their source messages are no longer visible. - history.record_retained_context(&codex_history::RetainedContextEvent::VerifiedAnswer( - codex_history::VerifiedAnswer { + history.record_retained_context(&codex_history::RetainedContextEvent::VerifiedAnswer { + answer: codex_history::VerifiedAnswer { turn_id: "checkpoint-turn".to_owned(), call_id: "ask-1".to_owned(), questions: vec![codex_history::VerifiedQuestionAnswer { @@ -1261,7 +1261,8 @@ fn drop_last_n_user_turns_preserves_prefix() { answer: "Only privately.".to_owned(), }], }, - )); + acceptance_order: None, + }); let retained = history.retained_context().clone(); history.drop_last_n_user_turns(/*num_turns*/ 1); assert_eq!(history.retained_context(), &retained); @@ -1292,8 +1293,8 @@ fn drop_last_n_user_turns_preserves_prefix() { }; history.record_items([&message], TruncationPolicy::Tokens(10_000)); if id == "restriction" { - history.record_retained_context(&codex_history::RetainedContextEvent::VerifiedAnswer( - codex_history::VerifiedAnswer { + history.record_retained_context(&codex_history::RetainedContextEvent::VerifiedAnswer { + answer: codex_history::VerifiedAnswer { turn_id: "shared-turn".to_owned(), call_id: "ask".to_owned(), questions: vec![codex_history::VerifiedQuestionAnswer { @@ -1301,7 +1302,8 @@ fn drop_last_n_user_turns_preserves_prefix() { answer: "Only privately.".to_owned(), }], }, - )); + acceptance_order: None, + }); expected = Some(history.retained_context().clone()); } } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 4964905418..e17f1508e7 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -705,11 +705,16 @@ pub(crate) async fn record_pending_input( persist_context: PersistContext, ) { match pending_input { - TurnInput::UserInput { content, client_id } => { + TurnInput::UserInput { + content, + client_id, + acceptance_order, + } => { sess.record_user_prompt_and_emit_turn_item( turn_context.as_ref(), content.as_slice(), client_id, + acceptance_order, persist_context, ) .await; diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 1d4f8fc26c..6a9898c562 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -311,7 +311,7 @@ async fn should_install_mcp_dependencies( sess.notify_user_input_response(sub_id, empty.clone()).await; empty } - response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { + response = response_fut => response.map(|accepted| accepted.response).unwrap_or_else(|| RequestUserInputResponse { answers: HashMap::new(), }), }; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 9d44f614ca..ca80687ffc 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1620,7 +1620,7 @@ pub(crate) async fn request_mcp_tool_user_approval( .request_user_input(turn_context, call_id.to_string(), args) .await; normalize_approval_decision_for_mode( - parse_mcp_tool_approval_response(response, &question_id), + parse_mcp_tool_approval_response(response.map(|accepted| accepted.response), &question_id), *approval_mode, ) } diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 3993abf7dc..94d3dfeb4d 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -23,6 +23,8 @@ pub enum TurnInput { UserInput { content: Vec, client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acceptance_order: Option, }, FunctionCallOutput(ResponseItem), // Preserve the existing serialized format while carrying injection API metadata @@ -487,6 +489,7 @@ mod tests { .extend_pending_input_and_accept_mailbox_delivery_for_turn_state( &turn_state, vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -519,6 +522,7 @@ mod tests { .extend_pending_input_and_accept_mailbox_delivery_for_turn_state( &turn_state, vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "already pending".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 00d3e8e252..1e05614090 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -293,6 +293,7 @@ use crate::network_policy_decision::execpolicy_network_rule_amendment; use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; +use crate::state::AcceptedUserInputResponse; use crate::state::AutoCompactWindowIds; use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; @@ -319,6 +320,7 @@ use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::PluginsManager; use codex_core_plugins::RecommendedPluginCandidatesInput; use codex_git_utils::get_git_repo_root; +use codex_history::CodexHarnessMetadata; use codex_history::CompactedItem; use codex_history::InitialHistory; use codex_history::ResponseItemEnvelope; @@ -2947,12 +2949,12 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub async fn request_user_input( + pub(crate) async fn request_user_input( &self, turn_context: &TurnContext, call_id: String, args: RequestUserInputArgs, - ) -> Option { + ) -> Option { let _elicitation = self.services.elicitations.register(); let sub_id = turn_context.sub_id.clone(); let (tx_response, rx_response) = oneshot::channel(); @@ -2998,15 +3000,23 @@ impl Session { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.remove_pending_user_input(sub_id) + let sender = at.turn_state.lock().await.remove_pending_user_input(sub_id); + match sender { + Some(sender) => Some((sender, self.reserve_user_input_order().await)), + None => None, + } } None => None, } }; match entry { - Some(tx_response) => { - tx_response.send(response).ok(); + Some((tx_response, acceptance_order)) => { + tx_response + .send(AcceptedUserInputResponse { + response, + acceptance_order, + }) + .ok(); } None => { warn!("No pending user input found for sub_id: {sub_id}"); @@ -4539,14 +4549,24 @@ impl Session { turn_context: &TurnContext, input: &[UserInput], client_id: Option, + acceptance_order: Option, persist_context: PersistContext, ) { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. let response_item = self.response_item_from_user_input(input.to_vec()); - self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) - .await; + self.record_annotated_conversation_items( + turn_context, + vec![ResponseItemEnvelope { + item: response_item, + metadata: acceptance_order.map(|order| CodexHarnessMetadata { + user_input_order: Some(order), + ..Default::default() + }), + }], + ) + .await; let mut user_message_item = UserMessageItem::new(input); user_message_item.client_id = client_id; let turn_item = TurnItem::UserMessage(user_message_item); diff --git a/codex-rs/core/src/session/retained_context.rs b/codex-rs/core/src/session/retained_context.rs index 9fa962856b..71506e68f4 100644 --- a/codex-rs/core/src/session/retained_context.rs +++ b/codex-rs/core/src/session/retained_context.rs @@ -1,5 +1,6 @@ -//! Records host-verified facts at the same persistence boundary as context checkpoints. +//! Orders retained inputs and records host-verified facts at the context checkpoint boundary. +use codex_features::Feature; use codex_history::RetainedContextEvent; use codex_history::RolloutItem; @@ -7,6 +8,14 @@ use super::Session; use super::thread_settings; impl Session { + /// Legacy mode neither reserves a sequence nor takes the session-state lock. + pub(crate) async fn reserve_user_input_order(&self) -> Option { + if !self.enabled(Feature::GuardianThreadContext) { + return None; + } + Some(self.state.lock().await.history.reserve_input_order()) + } + pub(crate) async fn record_retained_context(&self, mut event: RetainedContextEvent) { event.bound(); // Share the checkpoint persistence lock so a fact cannot land on the wrong side diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index a2453d227b..202aae4aca 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -183,6 +183,7 @@ pub(super) async fn spawn_review_thread( // Seed the child task with the review prompt as the initial user message. let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: review_prompt, // Review prompt is synthesized; no UI element ranges to preserve. diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index ef0ce0503d..c76b9b4fec 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -1478,15 +1478,17 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta async fn reconstruct_history_legacy_compaction_without_replacement_history_does_not_inject_current_initial_context() { let (session, turn_context) = make_session_and_context().await; - let answer = - codex_history::RetainedContextEvent::VerifiedAnswer(codex_history::VerifiedAnswer { + let answer = codex_history::RetainedContextEvent::VerifiedAnswer { + answer: codex_history::VerifiedAnswer { turn_id: "legacy-turn".to_owned(), call_id: "ask-1".to_owned(), questions: vec![codex_history::VerifiedQuestionAnswer { question: "Upload?".to_owned(), answer: "Only privately.".to_owned(), }], - }); + }, + acceptance_order: None, + }; let mut retained = codex_history::RetainedContext::default(); retained.mark_user_messages_incomplete(); retained.record(&answer); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d9c1f72214..f302a92a07 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8154,6 +8154,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { sess.spawn_task( Arc::clone(&tc), vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -9698,6 +9699,7 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task sess.set_previous_turn_settings(/*previous_turn_settings*/ None) .await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -11468,6 +11470,7 @@ async fn guardian_auto_review_emits_thread_idle_after_interrupt() { async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "keep turn active for helper reviews".to_string(), text_elements: Vec::new(), @@ -11535,6 +11538,7 @@ async fn turn_complete_flushes_terminal_event_after_delivery() { .await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "complete normally".to_string(), text_elements: Vec::new(), @@ -11562,6 +11566,7 @@ async fn turn_aborted_flushes_terminal_event_after_delivery() { .await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "interrupt me".to_string(), text_elements: Vec::new(), @@ -11604,6 +11609,7 @@ async fn turn_aborted_flushes_terminal_event_after_delivery() { async fn abort_regular_task_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -11645,6 +11651,7 @@ async fn abort_regular_task_emits_marker_before_turn_aborted() { async fn abort_gracefully_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -11706,6 +11713,7 @@ async fn submit_steer_only( 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![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -12143,6 +12151,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { (sess.input_queue.get_pending_input(&sess.active_turn).await).0, vec![ TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), @@ -12199,6 +12208,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { (sess.input_queue.get_pending_input(&sess.active_turn).await).0, vec![ TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), @@ -12270,6 +12280,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "start review".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index 79df5facfa..fe7b5ce32b 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -313,7 +313,7 @@ async fn start_or_steer( } let mut task_input = merge_additional_context_input(session, additional_context).await; if has_explicit_input { - task_input.push(pending_turn_input(input)); + task_input.push(pending_turn_input(session, input).await); } session .spawn_task(turn_context, task_input, RegularTask::new()) @@ -424,7 +424,7 @@ async fn start_if_idle( if let SubmittedTurnInput::UserInput { content, .. } = &input { turn_context.session_telemetry.user_prompt(content); } - task_input.push(pending_turn_input(input)); + task_input.push(pending_turn_input(session, input).await); } TurnStartKind::Automatic => { // Empty automatic user input resumes sampling without a new message. @@ -433,7 +433,7 @@ async fn start_if_idle( .input_queue .extend_pending_input_for_turn_state( turn_state.as_ref(), - vec![pending_turn_input(input)], + vec![pending_turn_input(session, input).await], ) .await; } @@ -621,9 +621,10 @@ impl Session { TurnInput::UserInput { content: std::mem::take(content), client_id: client_id.clone(), + acceptance_order: self.reserve_user_input_order().await, } } - input => pending_turn_input(input.clone()), + input => pending_turn_input(self, input.clone()).await, }; pending_input.push(input); if active_task @@ -663,11 +664,13 @@ async fn merge_additional_context_input( .collect() } -fn pending_turn_input(input: SubmittedTurnInput) -> TurnInput { +async fn pending_turn_input(session: &Session, input: SubmittedTurnInput) -> TurnInput { match input { - SubmittedTurnInput::UserInput { content, client_id } => { - TurnInput::UserInput { content, client_id } - } + SubmittedTurnInput::UserInput { content, client_id } => TurnInput::UserInput { + content, + client_id, + acceptance_order: session.reserve_user_input_order().await, + }, SubmittedTurnInput::ResponseItem(mut item) if matches!( &item, diff --git a/codex-rs/core/src/session/turn_input_tests.rs b/codex-rs/core/src/session/turn_input_tests.rs index c9810df1f4..8c2d7c5b6a 100644 --- a/codex-rs/core/src/session/turn_input_tests.rs +++ b/codex-rs/core/src/session/turn_input_tests.rs @@ -862,6 +862,7 @@ async fn steer_only_enforces_expected_turn_id() { .spawn_task( Arc::clone(&turn_context), vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -951,6 +952,7 @@ async fn rejects_non_regular_turns() { .spawn_task( Arc::clone(&turn_context), vec![TurnInput::UserInput { + acceptance_order: None, content: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 16297ea417..218b25ba92 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -10,6 +10,7 @@ pub(crate) use auto_compact_window::AutoCompactWindowIds; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; +pub(crate) use turn::AcceptedUserInputResponse; pub(crate) use turn::ActiveTurn; pub(crate) use turn::MailboxDeliveryPhase; pub(crate) use turn::PendingRequestPermissions; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index c61e6431e9..b03dbf737f 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,4 +1,4 @@ -//! Turn-scoped state and active turn metadata scaffolding. +//! Turn-scoped state, input waiters, and active turn metadata. use std::collections::HashMap; use std::sync::Arc; @@ -90,7 +90,7 @@ pub(crate) struct RunningTask { pub(crate) struct TurnState { pending_approvals: HashMap>, pending_request_permissions: HashMap, - pending_user_input: HashMap>, + pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, mcp_tool_approval_metadata: HashMap, McpToolApprovalMetadata)>, pending_dynamic_tools: HashMap>, @@ -106,6 +106,13 @@ pub(crate) struct TurnState { pub(crate) last_known_step_context: Option>, } +/// Host receipt metadata follows the response through the asynchronous tool waiter. +pub(crate) struct AcceptedUserInputResponse { + pub(crate) response: RequestUserInputResponse, + /// Absent in legacy mode, which does not reserve or persist acceptance order. + pub(crate) acceptance_order: Option, +} + pub(crate) struct PendingRequestPermissions { pub(crate) tx_response: oneshot::Sender, pub(crate) requested_permissions: RequestPermissionProfile, @@ -156,15 +163,15 @@ impl TurnState { pub(crate) fn insert_pending_user_input( &mut self, key: String, - tx: oneshot::Sender, - ) -> Option> { + tx: oneshot::Sender, + ) -> Option> { self.pending_user_input.insert(key, tx) } pub(crate) fn remove_pending_user_input( &mut self, key: &str, - ) -> Option> { + ) -> Option> { self.pending_user_input.remove(key) } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 10562985e4..a00781ed24 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -85,7 +85,7 @@ impl RequestUserInputHandler { auto_resolution_ms: None, }; let questions = args.questions.clone(); - let response = session + let accepted = session .request_user_input(turn.as_ref(), call_id.clone(), args) .await .ok_or_else(|| { @@ -94,6 +94,8 @@ impl RequestUserInputHandler { )) })?; + let response = accepted.response; + let content = serde_json::to_string(&response).map_err(|err| { FunctionCallError::Fatal(format!( "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}" @@ -142,11 +144,14 @@ impl RequestUserInputHandler { .collect::>(); if !user_input.is_empty() { session - .record_retained_context(RetainedContextEvent::VerifiedAnswer(VerifiedAnswer { - turn_id: turn.sub_id.clone(), - call_id, - questions: user_input, - })) + .record_retained_context(RetainedContextEvent::VerifiedAnswer { + answer: VerifiedAnswer { + turn_id: turn.sub_id.clone(), + call_id, + questions: user_input, + }, + acceptance_order: accepted.acceptance_order, + }) .await; } } diff --git a/codex-rs/core/tests/suite/guardian_retained_context.rs b/codex-rs/core/tests/suite/guardian_retained_context.rs index 0d2048fe3e..3c0c7b9442 100644 --- a/codex-rs/core/tests/suite/guardian_retained_context.rs +++ b/codex-rs/core/tests/suite/guardian_retained_context.rs @@ -51,6 +51,7 @@ async fn record_answer( server: &MockServer, call_id: &str, answer: &str, + acceptance_order: u64, ) -> Result { let question = format!("May I publish {call_id}?"); mount_sse_sequence( @@ -109,8 +110,10 @@ async fn record_answer( }], }; thread.ensure_rollout_materialized().await; - let event = - RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer(retained.clone())); + let event = RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer { + answer: retained.clone(), + acceptance_order: Some(acceptance_order), + }); // Repeated delivery must not duplicate evidence during replay. thread.append_rollout_items(&[event.clone(), event]).await?; Ok(retained) @@ -299,6 +302,9 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( matches!(event, EventMsg::TurnComplete(_)) }) .await; + // Rebuild from source events before there is a compaction checkpoint. The answer + // was persisted first, but the accepted steering instruction must retain order 1. + let thread = resume(&test, &test.codex).await?; assert_eq!(answers[0].turn_id, answers[1].turn_id); let requests = response_mock.requests(); assert_eq!(requests.len(), 3); @@ -316,7 +322,7 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( ); } } - let history = test.codex.conversation_history_snapshot().await; + let history = thread.conversation_history_snapshot().await; let user_messages = [INITIAL, STEER] .into_iter() .enumerate() @@ -336,7 +342,7 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( .and_then(ResponseItem::id) .expect("original user-message identity"); json!({ - "order": index * 2, "turn_id": answers[index].turn_id, + "order": index, "turn_id": answers[index].turn_id, "message_id": message_id.as_str(), "text": text, "complete": true, }) }) @@ -346,7 +352,7 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( .enumerate() .map(|(index, answer)| { let mut value = json!(answer); - value["order"] = json!(index * 2 + 1); + value["order"] = json!(index + 2); value }) .collect::>(); @@ -360,19 +366,24 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( "verified_answers": [], "incomplete": false, "next_order": 0, }); answers.clear(); - assert!( - load_context(&test, &test.codex) - .await? - .iter() - .all(|item| !matches!(item, RolloutItem::RetainedContext(_))) - ); + for item in load_context(&test, &thread).await? { + assert!(!matches!(item, RolloutItem::RetainedContext(_))); + if let RolloutItem::ResponseItem(envelope) = item { + assert!( + envelope + .metadata + .as_ref() + .is_none_or(|metadata| metadata.user_input_order.is_none()) + ); + } + } } assert_eq!(serde_json::to_value(history.retained_context())?, expected); assert_eq!( - serde_json::to_value(compact_and_assert_answers(&test, &test.codex, &answers).await?)?, + serde_json::to_value(compact_and_assert_answers(&test, &thread, &answers).await?)?, expected ); - let compacted = test.codex.conversation_history_snapshot().await; + let compacted = thread.conversation_history_snapshot().await; for message in &user_messages { assert_eq!( compacted @@ -382,7 +393,7 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( "only thread-owned context preserves original user-message identity" ); } - let mut thread = Arc::clone(&test.codex); + let mut thread = thread; for &remaining in rollback_counts { let remaining = if thread_context_enabled { remaining } else { 0 }; thread = resume(&test, &thread).await?; @@ -407,11 +418,9 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( expected["verified_answers"] .as_array_mut() .expect("expected retained verified answers") - .truncate(remaining); + .clear(); assert_eq!( - serde_json::to_value( - compact_and_assert_answers(&test, &thread, &answers[..remaining]).await? - )?, + serde_json::to_value(compact_and_assert_answers(&test, &thread, &[]).await?)?, expected ); } @@ -537,11 +546,12 @@ async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()> .with_history_mode(ThreadHistoryMode::Legacy) .with_config(|config| { config.experimental_thread_store = ThreadStoreConfig::Local; - for feature in [ - Feature::TokenBudget, - Feature::DefaultModeRequestUserInput, - Feature::GuardianThreadContext, - ] { + // Legacy saved answers use their source calls, not acceptance-order metadata. + config + .features + .disable(Feature::GuardianThreadContext) + .expect("legacy evidence fixture"); + for feature in [Feature::TokenBudget, Feature::DefaultModeRequestUserInput] { config .features .enable(feature) @@ -653,7 +663,10 @@ async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()> .iter() .cloned() .map(|answer| { - RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer(answer)) + RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order: None, + }) }) .collect::>(), ) @@ -714,6 +727,7 @@ async fn retained_answers_cross_real_session_boundaries( &server, "before-compact", "Only publish privately.", + /*acceptance_order*/ 1, ) .await?; let thread = resume(&test, &test.codex).await?; @@ -724,6 +738,7 @@ async fn retained_answers_cross_real_session_boundaries( &server, "after-compact", "Do not publish after all.", + /*acceptance_order*/ 3, ) .await?; thread.flush_rollout().await?; @@ -756,7 +771,10 @@ async fn retained_answers_cross_real_session_boundaries( events.dedup(); assert_eq!( events, - vec![RetainedContextEvent::VerifiedAnswer(after.clone())] + vec![RetainedContextEvent::VerifiedAnswer { + answer: after.clone(), + acceptance_order: Some(3) + }] ); let thread = resume(&test, &thread).await?; let expected = [before.clone(), after]; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 090ab49a0a..112f64e08d 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -70,6 +70,7 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -2649,8 +2650,12 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() Ok(()) } +#[test_case::test_case(/*thread_context_enabled*/ true; "retained context enabled")] +#[test_case::test_case(/*thread_context_enabled*/ false; "retained context disabled")] #[tokio::test] -async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Result<()> { +async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt( + thread_context_enabled: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); @@ -2693,7 +2698,13 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu write_user_prompt_submit_hook(home, "blocked queued prompt", BLOCKED_PROMPT_CONTEXT) .expect("failed to write user prompt submit hook test fixture"); }) - .with_config(trust_discovered_hooks); + .with_config(move |config| { + trust_discovered_hooks(config); + config + .features + .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) + .expect("test context mode"); + }); let test = builder.build_with_streaming_server(&server).await?; test.codex @@ -2748,6 +2759,29 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu "second request should not include the blocked queued prompt", ); + let history = test.codex.conversation_history_snapshot().await; + let retained = serde_json::to_value(history.retained_context())?; + assert_eq!( + retained["user_messages"] + .as_array() + .expect("retained user messages") + .iter() + .map(|message| (message["order"].clone(), message["text"].clone())) + .collect::>(), + if thread_context_enabled { + vec![ + (json!(0), json!("initial prompt")), + (json!(1), json!("accepted queued prompt")), + ] + } else { + Vec::new() + }, + ); + assert_eq!( + retained["next_order"], + json!(if thread_context_enabled { 3 } else { 0 }) + ); + let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?; assert_eq!(hook_inputs.len(), 3); assert_eq!( diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index a77b1a2e66..43d0231ee2 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -62,6 +62,10 @@ pub struct CodexHarnessMetadata { /// Producer compatibility for an opaque compaction item, never the currently selected model. #[serde(default, skip_serializing_if = "Option::is_none")] pub compaction_model_hash: Option, + + /// Thread acceptance order, independent of when queued user input reaches model history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_input_order: Option, } impl ResponseItemEnvelope { diff --git a/codex-rs/history/src/retained_context.rs b/codex-rs/history/src/retained_context.rs index 350dbdf84b..f02987367d 100644 --- a/codex-rs/history/src/retained_context.rs +++ b/codex-rs/history/src/retained_context.rs @@ -73,11 +73,17 @@ impl std::fmt::Debug for VerifiedAnswer { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RetainedContextEvent { - VerifiedAnswer(VerifiedAnswer), + VerifiedAnswer { + #[serde(flatten)] + answer: VerifiedAnswer, + /// Absent in legacy events, which retain their recorded ordering. + #[serde(default, skip_serializing_if = "Option::is_none")] + acceptance_order: Option, + }, } /// Bounded snapshot of retained families, persisted with the parent compaction checkpoint. -/// Facts live until their source instruction is rolled back; compaction does not expire them. +/// Facts live until their instruction boundary is rolled back; compaction does not expire them. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RetainedContext { verified_answers: VecDeque>, @@ -98,6 +104,9 @@ fn legacy_user_messages_incomplete() -> bool { } fn bound_family(items: &mut VecDeque>, incomplete: &mut bool) { + // Queued instructions can be recorded after later-accepted answers. Evict by + // acceptance order, not by the order in which persistence happened to finish. + items.make_contiguous().sort_by_key(|entry| entry.order); while items.len() > MAX_FAMILY_RECORDS || serde_json::to_vec(items).map_or(true, |bytes| bytes.len() > MAX_FAMILY_BYTES) { @@ -124,7 +133,7 @@ impl RetainedContextEvent { /// Bounds a persisted event before it enters the rollout or the live snapshot. pub fn bound(&mut self) { match self { - Self::VerifiedAnswer(answer) => { + Self::VerifiedAnswer { answer, .. } => { if serde_json::to_vec(answer).map_or(true, |bytes| bytes.len() > MAX_RECORD_BYTES) { answer.questions.clear(); // IDs are correlation metadata, not model-visible authorization text. @@ -141,6 +150,19 @@ impl RetainedContextEvent { } impl RetainedContext { + /// Reserves order without retaining pending input that hooks may reject or cancel. + pub fn reserve_order(&mut self) -> u64 { + let order = self.next_order; + self.next_order = self.next_order.saturating_add(1); + order + } + + fn record_order(&mut self, acceptance_order: Option) -> u64 { + let order = acceptance_order.unwrap_or(self.next_order); + self.next_order = self.next_order.max(order.saturating_add(1)); + order + } + pub fn verified_answers(&self) -> impl DoubleEndedIterator { self.verified_answers.iter().map(|entry| &entry.value) } @@ -186,8 +208,13 @@ impl RetainedContext { entries.into_iter().map(|(_, entry)| entry) } - /// Records the already-persisted user item; checkpoint/suffix replay uses this same path. - pub fn record_user_message(&mut self, mut message: RetainedUserMessage) { + /// Records a delivered user item with its acceptance order. Legacy items without + /// this metadata use recording order; checkpoint/suffix replay uses the same path. + pub fn record_user_message( + &mut self, + mut message: RetainedUserMessage, + acceptance_order: Option, + ) { message.bound(); if let Some(index) = self.user_messages.iter().position(|entry| { message.message_id.is_some() && entry.value.message_id == message.message_id @@ -197,11 +224,11 @@ impl RetainedContext { } self.user_messages.remove(index); } + let order = self.record_order(acceptance_order); self.user_messages.push_back(Ordered { - order: self.next_order, + order, value: message, }); - self.next_order = self.next_order.saturating_add(1); bound_family(&mut self.user_messages, &mut self.user_messages_incomplete); } @@ -210,7 +237,10 @@ impl RetainedContext { let mut event = event.clone(); event.bound(); match event { - RetainedContextEvent::VerifiedAnswer(answer) => { + RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order, + } => { if let Some(index) = self.verified_answers.iter().position(|existing| { existing.value.turn_id == answer.turn_id && existing.value.call_id == answer.call_id @@ -220,11 +250,11 @@ impl RetainedContext { } self.verified_answers.remove(index); } + let order = self.record_order(acceptance_order); self.verified_answers.push_back(Ordered { - order: self.next_order, + order, value: answer, }); - self.next_order = self.next_order.saturating_add(1); bound_family( &mut self.verified_answers, &mut self.verified_answers_incomplete, @@ -242,9 +272,12 @@ impl RetainedContext { ..Self::default() }); for entry in &mut self.verified_answers { - let mut event = RetainedContextEvent::VerifiedAnswer(entry.value.clone()); + let mut event = RetainedContextEvent::VerifiedAnswer { + answer: entry.value.clone(), + acceptance_order: Some(entry.order), + }; event.bound(); - let RetainedContextEvent::VerifiedAnswer(answer) = event; + let RetainedContextEvent::VerifiedAnswer { answer, .. } = event; entry.value = answer; self.next_order = self.next_order.max(entry.order.saturating_add(1)); } @@ -264,15 +297,23 @@ impl RetainedContext { self.verified_answers.retain(|answer| keep(&answer.value)); } - /// Rolls back at the original user-message boundary, including facts delivered after it. + /// Rolls back at the original user-message boundary, including later-accepted facts. + /// The explicit order also covers checkpoints made before a queued message was delivered. /// Steering can share a turn ID. Legacy sources without message identity fall back to /// source-turn removal and cannot establish complete retained user instructions. - pub fn rollback(&mut self, turn_ids: &[&str], first_removed_message_id: Option<&str>) { - if let Some(order) = first_removed_message_id.and_then(|id| { - self.user_messages - .iter() - .find(|message| message.value.message_id.as_deref() == Some(id)) - .map(|message| message.order) + pub fn rollback( + &mut self, + turn_ids: &[&str], + first_removed_message_id: Option<&str>, + acceptance_order: Option, + ) { + if let Some(order) = acceptance_order.or_else(|| { + first_removed_message_id.and_then(|id| { + self.user_messages + .iter() + .find(|message| message.value.message_id.as_deref() == Some(id)) + .map(|message| message.order) + }) }) { self.verified_answers.retain(|entry| entry.order < order); self.user_messages.retain(|entry| entry.order < order); diff --git a/codex-rs/history/src/retained_context_tests.rs b/codex-rs/history/src/retained_context_tests.rs index 0e945c47e2..2157ee3022 100644 --- a/codex-rs/history/src/retained_context_tests.rs +++ b/codex-rs/history/src/retained_context_tests.rs @@ -2,14 +2,17 @@ use super::*; use pretty_assertions::assert_eq; fn publish_answer() -> RetainedContextEvent { - RetainedContextEvent::VerifiedAnswer(VerifiedAnswer { - turn_id: "turn-1".to_owned(), - call_id: "ask-1".to_owned(), - questions: vec![VerifiedQuestionAnswer { - question: "Publish?".to_owned(), - answer: "Yes, but never publicly.".to_owned(), - }], - }) + RetainedContextEvent::VerifiedAnswer { + answer: VerifiedAnswer { + turn_id: "turn-1".to_owned(), + call_id: "ask-1".to_owned(), + questions: vec![VerifiedQuestionAnswer { + question: "Publish?".to_owned(), + answer: "Yes, but never publicly.".to_owned(), + }], + }, + acceptance_order: None, + } } #[test] @@ -18,12 +21,15 @@ fn retained_evidence_preserves_order_through_checkpoint_and_rollback() { let first = publish_answer(); assert!(context.record(&first)); let before_restriction = context.clone(); - context.record_user_message(RetainedUserMessage { - turn_id: "revocation-turn".to_owned(), - message_id: Some("revocation".to_owned()), - text: "Do not publish after all.".to_owned(), - complete: true, - }); + context.record_user_message( + RetainedUserMessage { + turn_id: "revocation-turn".to_owned(), + message_id: Some("revocation".to_owned()), + text: "Do not publish after all.".to_owned(), + complete: true, + }, + /*acceptance_order*/ None, + ); let snapshot = context.clone(); assert!(!context.record(&first)); assert_eq!(context, snapshot); @@ -43,7 +49,11 @@ fn retained_evidence_preserves_order_through_checkpoint_and_rollback() { .collect::>(), vec!["Yes, but never publicly.", "Do not publish after all."] ); - restored.rollback(&["revocation-turn"], Some("revocation")); + restored.rollback( + &["revocation-turn"], + Some("revocation"), + /*acceptance_order*/ None, + ); assert_eq!( restored, RetainedContext { @@ -61,24 +71,31 @@ fn retained_families_enforce_storage_limits_without_changing_snapshots() { let snapshot = context.clone(); for index in 2..=10 { - context.record(&RetainedContextEvent::VerifiedAnswer(VerifiedAnswer { - turn_id: "turn-2".to_owned(), - call_id: format!("ask-{index}"), - questions: vec![VerifiedQuestionAnswer { - question: "Continue?".to_owned(), - answer: "Yes".to_owned(), - }], - })); + context.record(&RetainedContextEvent::VerifiedAnswer { + answer: VerifiedAnswer { + turn_id: "turn-2".to_owned(), + call_id: format!("ask-{index}"), + questions: vec![VerifiedQuestionAnswer { + question: "Continue?".to_owned(), + answer: "Yes".to_owned(), + }], + }, + acceptance_order: None, + }); } assert!(!context.verified_answers_complete()); assert_eq!(context.verified_answers().count(), MAX_FAMILY_RECORDS); - context.rollback(&["turn-2"], /*first_removed_message_id*/ None); + context.rollback( + &["turn-2"], + /*first_removed_message_id*/ None, + /*acceptance_order*/ None, + ); assert_eq!(context.verified_answers().count(), 0); assert!(!context.verified_answers_complete()); let mut restored = snapshot.clone(); let mut oversized = first.clone(); - let RetainedContextEvent::VerifiedAnswer(answer) = &mut oversized; + let RetainedContextEvent::VerifiedAnswer { answer, .. } = &mut oversized; answer.questions[0].answer = "a".repeat(MAX_RECORD_BYTES); restored.record(&oversized); assert!(!restored.verified_answers_complete()); @@ -100,12 +117,15 @@ fn retained_families_enforce_storage_limits_without_changing_snapshots() { "Yes, but never publicly." ); for index in 0..=MAX_FAMILY_RECORDS { - restored.record_user_message(RetainedUserMessage { - turn_id: "later-turn".to_owned(), - message_id: Some(format!("message-{index}")), - text: "Keep the repository private.".to_owned(), - complete: true, - }); + restored.record_user_message( + RetainedUserMessage { + turn_id: "later-turn".to_owned(), + message_id: Some(format!("message-{index}")), + text: "Keep the repository private.".to_owned(), + complete: true, + }, + /*acceptance_order*/ None, + ); } assert!(!restored.user_messages_complete()); assert_eq!( @@ -115,12 +135,29 @@ fn retained_families_enforce_storage_limits_without_changing_snapshots() { .count(), MAX_FAMILY_RECORDS ); - restored.record_user_message(RetainedUserMessage { - turn_id: "oversized-turn".to_owned(), - message_id: Some("oversized-message".to_owned()), - text: "restriction ".repeat(MAX_RECORD_BYTES), - complete: true, - }); + let recent = restored.clone(); + restored.record_user_message( + RetainedUserMessage { + turn_id: "earlier-turn".to_owned(), + message_id: Some("delayed-message".to_owned()), + text: "An older queued instruction.".to_owned(), + complete: true, + }, + Some(0), + ); + assert_eq!( + restored, recent, + "delayed old input must not evict newer evidence" + ); + restored.record_user_message( + RetainedUserMessage { + turn_id: "oversized-turn".to_owned(), + message_id: Some("oversized-message".to_owned()), + text: "restriction ".repeat(MAX_RECORD_BYTES), + complete: true, + }, + /*acceptance_order*/ None, + ); let Some(RetainedContextEntry::UserMessage(message)) = restored.ordered_entries().next_back() else { panic!("latest user evidence"); @@ -130,7 +167,7 @@ fn retained_families_enforce_storage_limits_without_changing_snapshots() { #[test] fn legacy_checkpoints_mark_user_messages_incomplete() { - let RetainedContextEvent::VerifiedAnswer(answer) = publish_answer(); + let RetainedContextEvent::VerifiedAnswer { answer, .. } = publish_answer(); let mut wire = serde_json::json!({ "verified_answers": [answer], "incomplete": false }); @@ -150,3 +187,53 @@ fn legacy_checkpoints_mark_user_messages_incomplete() { restored.restore(/*checkpoint*/ None); assert!(!restored.user_messages_complete()); } + +#[test] +fn accepted_order_survives_delayed_recording_and_checkpoint_replay() { + let mut context = RetainedContext::default(); + // Rejected/canceled input consumes a sequence but never becomes evidence. + context.reserve_order(); + let steer_order = context.reserve_order(); + let answer_order = context.reserve_order(); + let RetainedContextEvent::VerifiedAnswer { answer, .. } = publish_answer(); + let event = RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order: Some(answer_order), + }; + context.record(&event); + let checkpoint = context.clone(); + let instruction = RetainedUserMessage { + turn_id: "turn-1".to_owned(), + message_id: Some("steer".to_owned()), + text: "Keep the repository private.".to_owned(), + complete: true, + }; + context.record_user_message(instruction.clone(), Some(steer_order)); + let mut resumed = RetainedContext::default(); + resumed.restore(Some(&checkpoint)); + resumed.record_user_message(instruction, Some(steer_order)); + assert_eq!(resumed, context); + assert_eq!( + resumed + .ordered_entries() + .map(|entry| match entry { + RetainedContextEntry::UserMessage(message) => message.text.as_str(), + RetainedContextEntry::VerifiedAnswer(answer) => answer.questions[0].answer.as_str(), + }) + .collect::>(), + vec!["Keep the repository private.", "Yes, but never publicly."], + ); + // This checkpoint predates model delivery of the queued instruction. Rollback + // still removes its later-accepted answer using the persisted boundary order. + let mut before_delivery = checkpoint; + before_delivery.rollback(&["turn-1"], Some("steer"), Some(steer_order)); + resumed.rollback(&["turn-1"], Some("steer"), Some(steer_order)); + assert_eq!(before_delivery, resumed); + assert_eq!( + resumed, + RetainedContext { + next_order: 3, + ..Default::default() + } + ); +} diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs index b16539ea7e..38f04e8c7a 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs @@ -42,9 +42,16 @@ struct PendingUserResponse { struct InstructionBoundary { record_index: usize, message_id: Option, + acceptance_order: Option, alive: bool, } +struct RetainedFactSource { + record_index: usize, + turn_id: String, + acceptance_order: Option, +} + /// Compact plan keyed by parsed source-record index. pub(super) struct RollbackPlan { record_boundaries: Vec>, @@ -97,7 +104,7 @@ pub(super) struct RollbackPlanner { pending_delivery_boundary: Option, turn_boundaries: HashMap, call_boundaries: HashMap<(String, String), Option>, - retained_fact_sources: Vec<(usize, String)>, + retained_fact_sources: Vec, compactions: Vec, model_replay: ModelReplayPlanner, } @@ -154,6 +161,10 @@ impl RollbackPlanner { } else if rollback::counts_as_boundary(&response.item) { let boundary = self.start_boundary(index); self.boundaries[boundary].message_id = response.id().cloned(); + self.boundaries[boundary].acceptance_order = response + .metadata + .as_ref() + .and_then(|metadata| metadata.user_input_order); if let ResponseItem::Message { role, content, .. } = &response.item && role == "user" { @@ -247,9 +258,10 @@ impl RollbackPlanner { self.assign_targeted_record(index, Some(record.turn_id.as_str())); } RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) => {} - RolloutItem::RetainedContext(codex_rollout::RetainedContextEvent::VerifiedAnswer( + RolloutItem::RetainedContext(codex_rollout::RetainedContextEvent::VerifiedAnswer { answer, - )) => { + acceptance_order, + }) => { let source = (answer.turn_id.clone(), answer.call_id.clone()); // A late answer still belongs to its call's instruction boundary, even // if the same running turn has since received another user steer. @@ -260,8 +272,11 @@ impl RollbackPlanner { } self.call_boundaries .insert(source, self.record_boundaries[index]); - self.retained_fact_sources - .push((index, answer.turn_id.clone())); + self.retained_fact_sources.push(RetainedFactSource { + record_index: index, + turn_id: answer.turn_id.clone(), + acceptance_order: *acceptance_order, + }); } RolloutItem::SecurityRiskScore(_) => self.record_boundaries[index] = None, } @@ -308,6 +323,7 @@ impl RollbackPlanner { self.boundaries.push(InstructionBoundary { record_index: index, message_id: None, + acceptance_order: None, alive: true, }); let had_prior_boundary = !self.boundary_stack.is_empty(); @@ -364,6 +380,18 @@ impl RollbackPlanner { } if let Some(boundary) = first_removed_boundary { let source = &self.boundaries[boundary]; + if let Some(order) = source.acceptance_order { + // An answer may have been persisted before the queued instruction + // accepted ahead of it. Both are removed at that acceptance boundary. + for fact in &self.retained_fact_sources { + if fact + .acceptance_order + .is_some_and(|accepted| accepted >= order) + { + self.record_boundaries[fact.record_index] = Some(boundary); + } + } + } let removed_turns = self .turn_boundaries .iter() @@ -372,29 +400,29 @@ impl RollbackPlanner { .chain( self.retained_fact_sources .iter() - .filter(|(index, _)| { - self.record_boundaries[*index] + .filter(|fact| { + self.record_boundaries[fact.record_index] .is_some_and(|boundary| removed_boundaries.contains(&boundary)) }) - .map(|(_, turn_id)| turn_id.as_str()), + .map(|fact| fact.turn_id.as_str()), ) .collect::>(); - // A checkpoint before this instruction cannot contain evidence from it. + // Accepted answers can precede a queued instruction in the rollout, + // including in checkpoints. Legacy evidence uses the recorded boundary. // Later checkpoints have not been observed yet and already reflect this rollback. - for frame in self - .compactions - .iter_mut() - .rev() - .take_while(|frame| frame.record_index >= source.record_index) - { + for frame in self.compactions.iter_mut().rev().take_while(|frame| { + source.acceptance_order.is_some() || frame.record_index >= source.record_index + }) { if let Some(context) = &mut frame.item.retained_context { - if context - .ordered_entries() - .any(|entry| matches!(entry, RetainedContextEntry::UserMessage(_))) + if source.acceptance_order.is_some() + || context + .ordered_entries() + .any(|entry| matches!(entry, RetainedContextEntry::UserMessage(_))) { context.rollback( &removed_turns, source.message_id.as_ref().map(ResponseItemId::as_str), + source.acceptance_order, ); } else { // Checkpoints written without instruction retention keep the legacy diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 0758db8163..82339f04dc 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -1230,7 +1230,12 @@ async fn migration_preserves_answers_before_a_rolled_back_steer() { checkpoint .verified_answers() .cloned() - .map(codex_rollout::RetainedContextEvent::VerifiedAnswer) + .map( + |answer| codex_rollout::RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order: None, + } + ) .collect::>(), answers[..1] ); @@ -1272,6 +1277,15 @@ async fn migration_preserves_reverse_replay_anchor_after_pre_compaction_rollback #[tokio::test] async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { + assert_migrated_evidence_order(/*steer_order*/ None).await; +} + +#[tokio::test] +async fn migration_removes_answers_accepted_after_a_queued_steer() { + assert_migrated_evidence_order(Some(1)).await; +} + +async fn assert_migrated_evidence_order(steer_order: Option) { const INITIAL: &str = "Never publish publicly."; const STEER: &str = "Also inspect the README."; let home = TempDir::new().expect("create Codex home"); @@ -1299,10 +1313,10 @@ async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { let retained = json!({ "user_messages": [ {"order": 0, "turn_id": "shared-turn", "message_id": "msg_initial", "text": INITIAL, "complete": true}, - {"order": 2, "turn_id": "shared-turn", "message_id": "msg_steer", "text": STEER, "complete": true} + {"order": steer_order.unwrap_or(2), "turn_id": "shared-turn", "message_id": "msg_steer", "text": STEER, "complete": true} ], "verified_answers": [ - {"order": 1, "turn_id": "shared-turn", "call_id": "before", "questions": [{"question": "Publish?", "answer": "Only privately."}]}, + {"order": if steer_order.is_some() { 2 } else { 1 }, "turn_id": "shared-turn", "call_id": "before", "questions": [{"question": "Publish?", "answer": "Only privately."}]}, {"order": 3, "turn_id": "shared-turn", "call_id": "after", "questions": [{"question": "Publish the README?", "answer": "Do not publish it."}]} ], "incomplete": false, "user_messages_incomplete": false, "next_order": 4 @@ -1315,8 +1329,26 @@ async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { .expect("retained checkpoint") .verified_answers() .cloned() - .map(codex_rollout::RetainedContextEvent::VerifiedAnswer) + .map(|answer| { + let acceptance_order = + steer_order.map(|_| if answer.call_id == "before" { 2 } else { 3 }); + codex_rollout::RetainedContextEvent::VerifiedAnswer { + answer, + acceptance_order, + } + }) .collect::>(); + let mut before_retained = retained.clone(); + before_retained["user_messages"] + .as_array_mut() + .expect("user messages") + .truncate(/*len*/ 1); + before_retained["verified_answers"] + .as_array_mut() + .expect("verified answers") + .truncate(/*len*/ 1); + before_retained["next_order"] = json!(if steer_order.is_some() { 3 } else { 2 }); + let remaining_answers = usize::from(steer_order.is_none()); let mut expected = retained; expected["user_messages"] .as_array_mut() @@ -1325,13 +1357,13 @@ async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { expected["verified_answers"] .as_array_mut() .expect("verified answers") - .truncate(/*len*/ 1); + .truncate(remaining_answers); let mut before_expected = expected.clone(); - before_expected["next_order"] = json!(2); + before_expected["next_order"] = before_retained["next_order"].clone(); let mut before_steer = checkpoint.clone(); before_steer.replacement_history = Some(vec![initial.clone().into()]); before_steer.retained_context = - Some(serde_json::from_value(before_expected.clone()).expect("pre-steer checkpoint")); + Some(serde_json::from_value(before_retained).expect("pre-steer checkpoint")); let mut after_rollback = before_steer.clone(); after_rollback.retained_context = Some(serde_json::from_value(expected.clone()).expect("post-rollback checkpoint")); @@ -1345,7 +1377,15 @@ async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { user_message(INITIAL), RolloutItem::RetainedContext(answers[0].clone()), RolloutItem::Compacted(before_steer), - rollout_response_item(steer), + RolloutItem::ResponseItem(codex_rollout::ResponseItemEnvelope { + item: steer, + metadata: steer_order.map(|order| { + serde_json::from_value(json!({ + "user_input_order": order + })) + .expect("acceptance metadata") + }), + }), user_message(STEER), RolloutItem::RetainedContext(answers[1].clone()), completed("shared-turn"), @@ -1399,7 +1439,7 @@ async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() { _ => None, }) .collect::>(), - vec![answers[0].clone()] + answers[..remaining_answers] ); }