Preserve verified answers across history compaction (#42293)

## Why

Compaction replaces the model history window, but host-verified user answers may
still be needed for the rest of the owning thread.

## What changed

- Add bounded, model-invisible retained context for verified
  `request_user_input` answers.
- Persist retained context in rollout events and compaction checkpoints, and
  restore it when a thread resumes.
- Remove answers when their source turns are rolled back, and prevent
  parent-local answers from being inherited by child forks.

## Testing

Add lifecycle coverage for deduplication and size limits, compaction and resume,
rollback, rollout migration, and child forks.

GitOrigin-RevId: c32900d3e8120a9899b71f0576683acd67a4b898
This commit is contained in:
felixxia-oai
2026-09-02 15:07:11 +00:00
committed by copyberry
parent 389dd56459
commit 5971d42847
45 changed files with 805 additions and 9 deletions
+1
View File
@@ -3183,6 +3183,7 @@ dependencies = [
"codex-config",
"codex-context-fragments",
"codex-exec-server-protocol",
"codex-history",
"codex-mcp",
"codex-protocol",
"codex-tools",
@@ -399,6 +399,7 @@ impl ThreadHistoryBuilder {
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::SessionMeta(_) => {}
}
@@ -4088,6 +4089,7 @@ mod tests {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -85,6 +85,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet {
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => ThreadHistoryChangeSet::default(),
}
@@ -201,6 +201,7 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() {
let compacted = project(RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -196,6 +196,7 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() ->
&RolloutItem::Compacted(CompactedItem {
message: "compacted history".to_string(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
+5 -1
View File
@@ -91,6 +91,7 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
RolloutItem::RealtimeItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_) => false,
// Full-history forks preserve the cached prompt prefix and can keep diffing
// from the parent's durable baseline. Truncated forks drop part of that prompt,
@@ -999,6 +1000,7 @@ impl AgentControl {
// Parent-local review evidence must not become the child's authorization.
// Root user authorization is collected separately by the host.
compacted.guardian_history = None;
compacted.retained_context = None;
if let Some(replacement_history) = compacted.replacement_history.as_mut() {
// Matches before this checkpoint cannot survive its replacement history.
replaced_parent_developer_instructions = false;
@@ -1023,7 +1025,9 @@ impl AgentControl {
| RolloutItem::TurnContext(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. } => true,
RolloutItem::TokenUsageRecord(_) | RolloutItem::SecurityRiskScore(_) => false,
RolloutItem::RetainedContext(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::SecurityRiskScore(_) => false,
}
});
// Full forks reuse the parent's reference context instead of rebuilding it. If that
+17
View File
@@ -277,6 +277,7 @@ async fn persisted_originator(thread: &CodexThread) -> String {
| RolloutItem::Compacted(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::TurnContext(_) => None,
@@ -1410,6 +1411,7 @@ async fn spawn_agent_fork_drops_inherited_token_usage_state() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(vec![user_message("compacted parent context").into()]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1526,6 +1528,7 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov
}
.into(),
]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -2017,6 +2020,12 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
internal_chat_message_metadata_passthrough: None,
},
];
let answer_event: codex_history::RetainedContextEvent = serde_json::from_value(serde_json::json!({
"type": "verified_answer", "turn_id": "parent-answer-turn", "call_id": "parent-answer-call",
"questions": [{"question": "Parent-local action?", "answer": "Parent only."}]
})).expect("verified answer fixture");
let mut retained_context = codex_history::RetainedContext::default();
retained_context.record(&answer_event);
parent_thread
.session
.persist_rollout_items(&[
@@ -2025,6 +2034,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
replacement_history: Some(
replacement_history.into_iter().map(Into::into).collect(),
),
retained_context: Some(retained_context),
guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![
user_message("Parent-local approval must not be inherited."),
])),
@@ -2036,6 +2046,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::RetainedContext(answer_event),
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
rollout_response_item(spawn_agent_call(&parent_spawn_call_id)),
])
@@ -2091,6 +2102,10 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
),
"a subagent must not inherit its parent review checkpoint",
);
assert_eq!(
history.retained_context(),
&codex_history::RetainedContext::default()
);
assert!(
history_contains_text(history.raw_items(), "compacted parent summary"),
"forked child history should retain compacted non-hint content"
@@ -2219,6 +2234,7 @@ async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_p
replacement_history: Some(
replacement_history.into_iter().map(Into::into).collect(),
),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -2375,6 +2391,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
RolloutItem::Compacted(CompactedItem {
message: "legacy compacted summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
+40 -2
View File
@@ -1,5 +1,6 @@
//! Model history and bounded original evidence for approval review.
//! Compaction replaces only model history; replay restores retained evidence and rollback trims it.
//! Parent model history and bounded host-owned context facts.
//! Compaction replaces only the model window. Snapshots include retained facts atomically;
//! checkpoint replay and source-turn rollback share their live lifecycle.
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
@@ -22,6 +23,8 @@ use codex_guardian_context::TranscriptHistory;
use codex_history::CodexHarnessMetadata;
use codex_history::GuardianHistoryCheckpoint;
use codex_history::ResponseItemEnvelope;
use codex_history::RetainedContext;
use codex_history::RetainedContextEvent;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
@@ -56,6 +59,8 @@ pub(crate) struct ContextManager {
items: Arc<Vec<ResponseItemEnvelope>>,
/// Starts at the first compaction; ordinary history snapshots need no second payload copy.
review_history: Option<TranscriptHistory>,
/// Host facts independent of the model window; snapshots share immutable state.
retained_context: Arc<RetainedContext>,
/// Bumped whenever history is rewritten, such as compaction or rollback.
history_version: u64,
/// Monotonic user-input/reset revision, independent of compaction's history generation.
@@ -79,6 +84,7 @@ pub(crate) struct ContextManager {
struct SharedConversationHistory {
items: Arc<Vec<ResponseItemEnvelope>>,
review_history: Option<TranscriptHistory>,
retained_context: Arc<RetainedContext>,
history_version: u64,
user_message_revision: u64,
}
@@ -89,6 +95,10 @@ pub(crate) enum HistoryReplacement {
}
impl ConversationHistorySnapshot for SharedConversationHistory {
fn retained_context(&self) -> Option<&RetainedContext> {
Some(&self.retained_context)
}
fn review_items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
match &self.review_history {
Some(history) => history.items(),
@@ -131,6 +141,7 @@ impl ContextManager {
Self {
items: Arc::new(Vec::new()),
review_history: None,
retained_context: Arc::default(),
history_version: 0,
user_message_revision: 0,
token_info: TokenUsageInfo::new_or_append(
@@ -145,11 +156,28 @@ impl ContextManager {
Arc::new(SharedConversationHistory {
items: Arc::clone(&self.items),
review_history: self.review_history.clone(),
retained_context: Arc::clone(&self.retained_context),
history_version: self.history_version,
user_message_revision: self.user_message_revision,
})
}
pub(crate) fn retained_context(&self) -> &RetainedContext {
&self.retained_context
}
pub(crate) fn record_retained_context(&mut self, event: &RetainedContextEvent) -> bool {
if !Arc::make_mut(&mut self.retained_context).record(event) {
return false;
}
self.user_message_revision = self.user_message_revision.saturating_add(1);
true
}
pub(crate) fn restore_retained_context(&mut self, checkpoint: &RetainedContext) {
Arc::make_mut(&mut self.retained_context).restore(checkpoint);
}
pub(crate) fn guardian_history_checkpoint(&self) -> Option<GuardianHistoryCheckpoint> {
self.review_history
.as_ref()
@@ -379,6 +407,7 @@ impl ContextManager {
}
pub(crate) fn replace_annotated(&mut self, items: Vec<ResponseItemEnvelope>) {
self.retained_context = Arc::default();
self.user_message_revision = self.user_message_revision.saturating_add(1);
if let Some(review_history) = &mut self.review_history {
review_history.reset(items.iter().map(|item| &item.item).filter(|item| {
@@ -432,7 +461,9 @@ impl ContextManager {
let snapshot = self.items.clone();
let user_positions = user_message_positions(&snapshot);
let Some(&first_instruction_turn_idx) = user_positions.first() else {
let retained_context = Arc::clone(&self.retained_context);
self.replace_annotated(Arc::unwrap_or_clone(snapshot));
self.retained_context = retained_context;
return;
};
@@ -478,7 +509,14 @@ impl ContextManager {
});
}
let mut retained_context = Arc::clone(&self.retained_context);
let removed_turns = snapshot[cut_idx..]
.iter()
.filter_map(|item| item.turn_id())
.collect::<Vec<_>>();
Arc::make_mut(&mut retained_context).remove_turns(&removed_turns);
self.replace_annotated(retained_items);
self.retained_context = retained_context;
self.review_history = review_history;
}
@@ -1121,9 +1121,24 @@ fn drop_last_n_user_turns_preserves_prefix() {
]);
history.drop_last_n_user_turns(/*num_turns*/ 99);
assert_eq!(
history.for_prompt(&modalities),
history.clone().for_prompt(&modalities),
vec![assistant_msg("session prefix item")]
);
// 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 {
turn_id: "checkpoint-turn".to_owned(),
call_id: "ask-1".to_owned(),
questions: vec![codex_history::VerifiedQuestionAnswer {
question: "Upload?".to_owned(),
answer: "Only privately.".to_owned(),
}],
},
));
let retained = history.retained_context().clone();
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(history.retained_context(), &retained);
}
#[test]
+4
View File
@@ -1540,6 +1540,7 @@ impl Session {
) -> Option<PreviousTurnSettings> {
let rollout_reconstruction::RolloutReconstruction {
mut history,
retained_context,
guardian_history,
previous_turn_settings,
reference_context_item,
@@ -1586,6 +1587,7 @@ impl Session {
state
.history
.restore_guardian_history(guardian_history.as_ref());
state.history.restore_retained_context(&retained_context);
if let Some(world_state) = world_state_baseline {
state.history.set_world_state_baseline(world_state);
}
@@ -3760,6 +3762,7 @@ impl Session {
let mut compacted_item = CompactedItem {
message: metadata.message,
replacement_history: Some(items.clone()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(),
window_number: Some(metadata.window_number),
@@ -3785,6 +3788,7 @@ impl Session {
HistoryReplacement::Compaction,
);
compacted_item.guardian_history = state.history.guardian_history_checkpoint();
compacted_item.retained_context = Some(state.history.retained_context().clone());
if let Some(world_state) = world_state_baseline {
let snapshot = world_state.snapshot();
world_state_item = Some(WorldStateItem::full(snapshot.clone().into_object()));
@@ -10,6 +10,7 @@ use uuid::Uuid;
#[derive(Debug)]
pub(super) struct RolloutReconstruction {
pub(super) history: Vec<ResponseItemEnvelope>,
pub(super) retained_context: codex_history::RetainedContext,
pub(super) guardian_history: Option<codex_history::GuardianHistoryCheckpoint>,
pub(super) previous_turn_settings: Option<PreviousTurnSettings>,
pub(super) reference_context_item: Option<TurnContextItem>,
@@ -300,6 +301,7 @@ impl Session {
RolloutItem::EventMsg(_)
| RolloutItem::SessionMeta(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::InterAgentCommunicationMetadata { .. } => {}
@@ -343,6 +345,9 @@ impl Session {
{
history.replace_annotated(items.clone());
history.restore_guardian_history(checkpoint.compacted.guardian_history.as_ref());
if let Some(retained) = &checkpoint.compacted.retained_context {
history.restore_retained_context(retained);
}
}
// Materialize exact history semantics from the replay-derived suffix. The eventual lazy
// design should keep this same replay shape, but drive it from a resumable reverse source
@@ -350,6 +355,9 @@ impl Session {
let rollout_suffix = base_compaction.map_or(rollout_items, |checkpoint| checkpoint.suffix);
for item in rollout_suffix {
match item {
RolloutItem::RetainedContext(event) => {
history.record_retained_context(event);
}
RolloutItem::ResponseItem(response_item) => {
history.record_annotated_items(
std::slice::from_ref(response_item),
@@ -385,7 +393,9 @@ impl Session {
&user_messages,
&compacted.message,
);
let retained_context = history.retained_context().clone();
history.replace_annotated(rebuilt);
history.restore_retained_context(&retained_context);
}
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
@@ -437,6 +447,7 @@ impl Session {
| RolloutItem::TurnContext(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => {
unreachable!("only world-state replay items are collected")
@@ -451,6 +462,7 @@ impl Session {
id: None,
});
RolloutReconstruction {
retained_context: history.retained_context().clone(),
guardian_history: history.guardian_history_checkpoint(),
history: history.into_annotated_items(),
previous_turn_settings,
@@ -1061,6 +1061,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1124,6 +1125,7 @@ async fn record_initial_history_requires_surviving_full_snapshot_without_user_tu
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1159,6 +1161,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1232,6 +1235,7 @@ async fn reconstruct_history_prefers_compacted_window_over_session_meta() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(2),
@@ -1271,6 +1275,7 @@ async fn reconstruct_history_replays_world_state_from_latest_compaction_window()
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
@@ -1336,6 +1341,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() {
replacement_history: Some(annotated(vec![assistant_message(&format!(
"summary-{window_number}"
))])),
retained_context: None,
guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![
user_message("original task"),
])),
@@ -1446,6 +1452,7 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1471,12 +1478,25 @@ 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 {
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(),
}],
});
let mut retained = codex_history::RetainedContext::default();
retained.record(&answer);
let rollout_items = vec![
RolloutItem::ResponseItem(user_message("before compact").into()),
RolloutItem::ResponseItem(assistant_message("assistant reply").into()),
RolloutItem::RetainedContext(answer),
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1500,6 +1520,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
])
);
assert!(reconstructed.reference_context_item.is_none());
assert_eq!(reconstructed.retained_context, retained);
}
#[tokio::test]
@@ -1516,6 +1537,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1624,6 +1646,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -1796,6 +1819,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -2054,6 +2078,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -2232,6 +2257,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
+1
View File
@@ -1369,6 +1369,7 @@ impl Session {
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_) => {}
}
}
+7
View File
@@ -2224,6 +2224,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
let rollout_items = vec![RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history.clone()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(42),
@@ -2905,6 +2906,7 @@ fn latest_token_usage_record_stops_at_compaction_checkpoint() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -3516,6 +3518,7 @@ async fn start_new_context_window_persists_checkpoint_state() {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
@@ -3605,6 +3608,7 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item
| RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
@@ -4170,6 +4174,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
.map(ResponseItemEnvelope::new)
.collect(),
),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(7),
@@ -12447,6 +12452,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary1.to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(window_number),
@@ -12480,6 +12486,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary2.to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(window_number),
@@ -0,0 +1,330 @@
//! Retained-answer lifecycle through real sessions and the durable thread-store boundary.
//! Typed fixtures enter via the public rollout append API to isolate storage from producer
//! wiring; compaction, resume, rollback and child forks use production paths.
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use codex_core::CodexThread;
use codex_core::TurnInputRequest;
use codex_core::config::ThreadStoreConfig;
use codex_features::Feature;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_history::RetainedContextEvent;
use codex_history::RolloutItem;
use codex_history::VerifiedAnswer;
use codex_history::VerifiedQuestionAnswer;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use codex_thread_store::LoadThreadHistoryParams;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
use wiremock::MockServer;
async fn record_answer(
thread: &CodexThread,
server: &MockServer,
call_id: &str,
answer: &str,
) -> Result<VerifiedAnswer> {
let question = format!("May I publish {call_id}?");
mount_sse_sequence(
server,
vec![
sse(vec![
ev_function_call(
call_id,
"request_user_input",
&json!({"questions": [{
"id": "publish", "header": "Publish", "question": question,
"options": [
{"label": "Yes", "description": "Publish privately."},
{"label": "No", "description": "Keep local."}
]
}]})
.to_string(),
),
ev_completed(&format!("ask-{call_id}")),
]),
sse(vec![ev_completed(&format!("answered-{call_id}"))]),
],
)
.await;
thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: format!("Check authorization for {call_id}."),
text_elements: Vec::new(),
}]))
.await?;
let request = wait_for_event_match(thread, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
thread
.submit(Op::UserInputAnswer {
id: request.turn_id.clone(),
response: RequestUserInputResponse {
answers: HashMap::from([(
"publish".to_owned(),
RequestUserInputAnswer {
answers: vec![answer.to_owned()],
},
)]),
},
})
.await?;
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let retained = VerifiedAnswer {
turn_id: request.turn_id,
call_id: call_id.to_owned(),
questions: vec![VerifiedQuestionAnswer {
question,
answer: answer.to_owned(),
}],
};
thread.ensure_rollout_materialized().await;
let event =
RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer(retained.clone()));
// Repeated delivery must not duplicate evidence during replay.
thread.append_rollout_items(&[event.clone(), event]).await?;
Ok(retained)
}
async fn load_context(test: &TestCodex, thread: &CodexThread) -> Result<Vec<RolloutItem>> {
Ok(test
.thread_store
.load_latest_model_context(LoadThreadHistoryParams {
thread_id: thread.session_configured().thread_id,
include_archived: false,
})
.await?
.items)
}
async fn resume(test: &TestCodex, thread: &CodexThread) -> Result<Arc<CodexThread>> {
let thread_id = thread.session_configured().thread_id;
thread.shutdown_and_wait().await?;
test.thread_manager.remove_thread(&thread_id).await;
let saved = load_context(test, thread).await?;
let items: Vec<RolloutItem> = serde_json::from_value(serde_json::to_value(saved)?)?;
Ok(test
.thread_manager
.resume_thread_with_history(
test.config.clone(),
InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Arc::new(items),
rollout_path: None,
}),
test.thread_manager.auth_manager(),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await?
.thread)
}
async fn compact_and_assert_answers(
test: &TestCodex,
thread: &CodexThread,
expected: &[VerifiedAnswer],
) -> Result<()> {
// Inspect live state by persisting a real compaction checkpoint, not a private getter.
// Repeating this after resume/rollback also catches checkpoint resurrection.
thread.submit(Op::Compact).await?;
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
thread.flush_rollout().await?;
let history = load_context(test, thread).await?;
let checkpoint = history
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::Compacted(checkpoint) => Some(checkpoint),
_ => None,
})
.context("compaction checkpoint")?;
let actual = checkpoint
.retained_context
.as_ref()
.context("retained context checkpoint")?;
assert_eq!(
actual.verified_answers().cloned().collect::<Vec<_>>(),
expected
);
Ok(())
}
#[derive(Clone, Copy)]
enum LifecycleBoundary {
Rollback,
ChildFork,
}
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::Rollback; "legacy rollback")]
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::ChildFork; "legacy child fork")]
#[test_case(ThreadHistoryMode::Paginated, LifecycleBoundary::ChildFork; "paginated child fork")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retained_answers_cross_real_session_boundaries(
history_mode: ThreadHistoryMode,
boundary: LifecycleBoundary,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex()
.with_history_mode(history_mode)
.with_config(|config| {
config.experimental_thread_store = ThreadStoreConfig::Local;
for feature in [
Feature::TokenBudget,
Feature::DefaultModeRequestUserInput,
Feature::Collab,
Feature::MultiAgentV2,
] {
config
.features
.enable(feature)
.expect("enable test feature");
}
})
.build_with_auto_env(&server)
.await?;
let before = record_answer(
&test.codex,
&server,
"before-compact",
"Only publish privately.",
)
.await?;
let thread = resume(&test, &test.codex).await?;
compact_and_assert_answers(&test, &thread, std::slice::from_ref(&before)).await?;
let after = record_answer(
&thread,
&server,
"after-compact",
"Do not publish after all.",
)
.await?;
thread.flush_rollout().await?;
let saved = load_context(&test, &thread).await?;
let checkpoint_index = saved
.iter()
.rposition(|item| matches!(item, RolloutItem::Compacted(_)))
.context("real compaction persisted a checkpoint")?;
let RolloutItem::Compacted(checkpoint) = &saved[checkpoint_index] else {
unreachable!()
};
assert_eq!(
checkpoint
.retained_context
.as_ref()
.context("retained checkpoint")?
.verified_answers()
.cloned()
.collect::<Vec<_>>(),
vec![before.clone()],
);
let suffix = &saved[checkpoint_index + 1..];
let mut events = suffix
.iter()
.filter_map(|item| match item {
RolloutItem::RetainedContext(event) => Some(event.clone()),
_ => None,
})
.collect::<Vec<_>>();
events.dedup();
assert_eq!(
events,
vec![RetainedContextEvent::VerifiedAnswer(after.clone())]
);
let thread = resume(&test, &thread).await?;
let expected = [before.clone(), after];
match boundary {
LifecycleBoundary::Rollback => {
// First remove the suffix answer, then the source retained only in the checkpoint.
let mut thread = thread;
for expected in [std::slice::from_ref(&before), &[]] {
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
wait_for_event(&thread, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
compact_and_assert_answers(&test, &thread, expected).await?;
thread = resume(&test, &thread).await?;
compact_and_assert_answers(&test, &thread, expected).await?;
}
thread.shutdown_and_wait().await?;
}
LifecycleBoundary::ChildFork => {
// Full-history delegation must not turn parent-local answers into child-local facts.
let mut created = test.thread_manager.subscribe_thread_created();
let arguments = json!({
"task_name": "worker",
"message": "Inspect without publishing.",
"fork_turns": "all",
});
mount_sse_sequence(
&server,
vec![
sse(vec![
ev_function_call_with_namespace(
"spawn",
"collaboration",
"spawn_agent",
&arguments.to_string(),
),
ev_completed("spawn-response"),
]),
sse(vec![ev_completed("first-fork-completion")]),
sse(vec![ev_completed("second-fork-completion")]),
],
)
.await;
thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Delegate an inspection.".to_owned(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let child = test.thread_manager.get_thread(created.try_recv()?).await?;
wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await;
child.flush_rollout().await?;
let child_history = load_context(&test, &child).await?;
assert!(
serde_json::to_string(&child_history)?.contains("Delegate an inspection."),
"the child must actually inherit parent conversation context"
);
compact_and_assert_answers(&test, &child, &[]).await?;
let child = resume(&test, &child).await?;
compact_and_assert_answers(&test, &child, &[]).await?;
child.shutdown_and_wait().await?;
compact_and_assert_answers(&test, &thread, &expected).await?;
thread.shutdown_and_wait().await?;
}
}
Ok(())
}
+1
View File
@@ -74,6 +74,7 @@ mod git_enrichment;
mod guardian_authorization;
mod guardian_history;
mod guardian_mcp_elicitation;
mod guardian_retained_context;
#[cfg(not(target_os = "windows"))]
mod guardian_review;
#[cfg(not(target_os = "windows"))]
+1
View File
@@ -14,6 +14,7 @@ doctest = false
workspace = true
[dependencies]
codex-history = { workspace = true }
codex-config = { workspace = true }
codex-context-fragments = { workspace = true }
codex-exec-server-protocol = { workspace = true }
@@ -1,3 +1,5 @@
use codex_history::RetainedContext;
use codex_protocol::models::ResponseItem;
/// Read-only conversation-history snapshot supplied by the extension host.
@@ -15,6 +17,11 @@ pub trait ConversationHistorySnapshot: Send + Sync {
/// Returns the snapshot's response items in conversation order.
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
/// Host-owned retained facts captured atomically with the parent model window.
fn retained_context(&self) -> Option<&RetainedContext> {
None
}
/// Original review evidence retained across parent compaction, in conversation order.
/// Hosts without separate retention provide their current history.
fn review_items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
@@ -244,6 +244,7 @@ fn source_model_items(items: &[RolloutItem]) -> Option<Vec<SourceModelItem<'_>>>
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::WorldState(_) => return None,
RolloutItem::EventMsg(_) => {}
@@ -260,6 +261,7 @@ fn history_model_items(items: &[RolloutItem]) -> Option<Vec<&ResponseItem>> {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_) => {}
RolloutItem::ResponseItem(response_item) => model_items.push(&response_item.item),
RolloutItem::EventMsg(
+1 -1
View File
@@ -16,8 +16,8 @@ workspace = true
codex-protocol = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
+9
View File
@@ -111,6 +111,7 @@ pub enum RolloutItem {
TokenUsageRecord(TokenUsageRecord),
WorldState(WorldStateItem),
SecurityRiskScore(SecurityRiskScore),
RetainedContext(RetainedContextEvent),
EventMsg(EventMsg),
/// Sparse, model-invisible facts used to reconstruct realtime presentation.
RealtimeItem(RealtimeItem),
@@ -149,6 +150,12 @@ impl JsonSchema for RolloutItem {
}
mod guardian_history;
mod retained_context;
pub use retained_context::RetainedContext;
pub use retained_context::RetainedContextEvent;
pub use retained_context::VerifiedAnswer;
pub use retained_context::VerifiedQuestionAnswer;
mod rollout_payload;
pub use guardian_history::GuardianHistoryCheckpoint;
@@ -158,6 +165,7 @@ pub struct CompactedItem {
pub message: String,
pub replacement_history: Option<Vec<ResponseItemEnvelope>>,
pub guardian_history: Option<GuardianHistoryCheckpoint>,
pub retained_context: Option<RetainedContext>,
pub mcp_resource_origins: Option<McpResourceOriginCheckpoint>,
pub window_number: Option<u64>,
pub first_window_id: Option<String>,
@@ -438,6 +446,7 @@ fn multi_agent_version_from_items(
| RolloutItem::Compacted(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::EventMsg(_) => None,
+135
View File
@@ -0,0 +1,135 @@
//! Bounded host-owned facts outside the model's compaction contract.
//! Checkpoints and live recording use the same admission rules; rendering is a consumer concern.
use std::collections::VecDeque;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
const MAX_ANSWERS: usize = 8;
const MAX_ANSWER_BYTES: usize = 16_384;
const MAX_ANSWERS_BYTES: usize = 65_536;
/// Original assistant question and host-verified user reply, never an inferred permission.
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct VerifiedQuestionAnswer {
pub question: String,
pub answer: String,
}
/// One accepted request_user_input response. Identity is local to the owning thread.
/// An omitted payload records incomplete evidence, rather than keeping a partial grant.
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct VerifiedAnswer {
pub turn_id: String,
pub call_id: String,
pub questions: Vec<VerifiedQuestionAnswer>,
}
impl std::fmt::Debug for VerifiedAnswer {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("VerifiedAnswer")
.field("questions", &self.questions.len())
.finish_non_exhaustive()
}
}
/// Sparse, model-invisible updates. Only the host may produce these records.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RetainedContextEvent {
VerifiedAnswer(VerifiedAnswer),
}
/// Bounded snapshot of retained families, persisted with the parent compaction checkpoint.
/// Facts live until their source turn is rolled back; compaction does not expire them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RetainedContext {
verified_answers: VecDeque<VerifiedAnswer>,
/// Lost evidence cannot be treated as a complete authorization history.
incomplete: bool,
}
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) => {
if serde_json::to_vec(answer).map_or(true, |bytes| bytes.len() > MAX_ANSWER_BYTES) {
answer.questions.clear();
// IDs are correlation metadata, not model-visible authorization text.
answer
.turn_id
.truncate(answer.turn_id.floor_char_boundary(1_024));
answer
.call_id
.truncate(answer.call_id.floor_char_boundary(1_024));
}
}
}
}
}
impl RetainedContext {
pub fn verified_answers(&self) -> impl DoubleEndedIterator<Item = &VerifiedAnswer> {
self.verified_answers.iter()
}
pub fn is_complete(&self) -> bool {
!self.incomplete
&& self
.verified_answers
.iter()
.all(|answer| !answer.questions.is_empty())
}
/// Same-event delivery is idempotent; changed contents replace that source's record.
pub fn record(&mut self, event: &RetainedContextEvent) -> bool {
let mut event = event.clone();
event.bound();
match event {
RetainedContextEvent::VerifiedAnswer(answer) => {
if let Some(index) = self.verified_answers.iter().position(|existing| {
existing.turn_id == answer.turn_id && existing.call_id == answer.call_id
}) {
if self.verified_answers[index] == answer {
return false;
}
self.verified_answers.remove(index);
}
self.verified_answers.push_back(answer);
while self.verified_answers.len() > MAX_ANSWERS
|| serde_json::to_vec(&self.verified_answers)
.map_or(true, |bytes| bytes.len() > MAX_ANSWERS_BYTES)
{
self.verified_answers.pop_front();
self.incomplete = true;
}
}
}
true
}
/// Restoring a saved thread must not bypass the live storage limits.
pub fn restore(&mut self, checkpoint: &Self) {
*self = Self {
incomplete: checkpoint.incomplete,
..Self::default()
};
for answer in &checkpoint.verified_answers {
self.record(&RetainedContextEvent::VerifiedAnswer(answer.clone()));
}
}
/// Removes facts with explicitly removed source turns, not merely compacted tool calls.
pub fn remove_turns(&mut self, turn_ids: &[&str]) {
self.verified_answers
.retain(|answer| !turn_ids.contains(&answer.turn_id.as_str()));
}
}
#[cfg(test)]
#[path = "retained_context_tests.rs"]
mod tests;
@@ -0,0 +1,64 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn answer_lifecycle_is_bounded_idempotent_and_checkpointed() {
let mut context = RetainedContext::default();
let first = 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(),
}],
});
assert!(context.record(&first));
let snapshot = context.clone();
assert!(!context.record(&first));
assert_eq!(context, snapshot);
let checkpoint =
serde_json::from_str(&serde_json::to_string(&context).expect("retained answer fixture"))
.expect("retained answer fixture");
let mut restored = RetainedContext::default();
restored.restore(&checkpoint);
assert_eq!(restored, snapshot);
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(),
}],
}));
}
assert!(!context.is_complete());
assert_eq!(context.verified_answers().count(), MAX_ANSWERS);
context.remove_turns(&["turn-2"]);
assert_eq!(context.verified_answers().count(), 0);
assert!(!context.is_complete());
let mut oversized = first.clone();
let RetainedContextEvent::VerifiedAnswer(answer) = &mut oversized;
answer.questions[0].answer = "a".repeat(MAX_ANSWER_BYTES);
restored.record(&oversized);
assert!(!restored.is_complete());
assert!(
restored
.verified_answers()
.next()
.expect("retained answer fixture")
.questions
.is_empty()
);
assert_eq!(
snapshot
.verified_answers()
.next()
.expect("retained answer fixture")
.questions[0]
.answer,
"Yes, but never publicly."
);
}
+13
View File
@@ -48,6 +48,9 @@ pub(super) enum RolloutItemWire<'a> {
WorldState {
payload: Cow<'a, WorldStateItem>,
},
RetainedContext {
payload: Cow<'a, crate::RetainedContextEvent>,
},
SecurityRiskScore {
payload: Cow<'a, SecurityRiskScore>,
},
@@ -91,6 +94,9 @@ impl<'a> From<&'a RolloutItem> for RolloutItemWire<'a> {
RolloutItem::WorldState(payload) => Self::WorldState {
payload: Cow::Borrowed(payload),
},
RolloutItem::RetainedContext(payload) => Self::RetainedContext {
payload: Cow::Borrowed(payload),
},
RolloutItem::SecurityRiskScore(payload) => Self::SecurityRiskScore {
payload: Cow::Borrowed(payload),
},
@@ -128,6 +134,9 @@ impl From<RolloutItemWire<'_>> for RolloutItem {
Self::TokenUsageRecord(payload.into_owned())
}
RolloutItemWire::WorldState { payload } => Self::WorldState(payload.into_owned()),
RolloutItemWire::RetainedContext { payload } => {
Self::RetainedContext(payload.into_owned())
}
RolloutItemWire::SecurityRiskScore { payload } => {
Self::SecurityRiskScore(payload.into_owned())
}
@@ -152,6 +161,8 @@ pub(super) struct CompactedItemWire<'a> {
#[serde(default, skip_serializing_if = "Option::is_none")]
guardian_history: Option<Cow<'a, crate::GuardianHistoryCheckpoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
retained_context: Option<Cow<'a, crate::RetainedContext>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mcp_resource_origins: Option<Cow<'a, McpResourceOriginCheckpoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window_number: Option<u64>,
@@ -194,6 +205,7 @@ impl<'a> From<&'a CompactedItem> for CompactedItemWire<'a> {
}),
replacement_history_metadata,
guardian_history: item.guardian_history.as_ref().map(Cow::Borrowed),
retained_context: item.retained_context.as_ref().map(Cow::Borrowed),
mcp_resource_origins: item.mcp_resource_origins.as_ref().map(Cow::Borrowed),
window_number: item.window_number,
first_window_id: item.first_window_id.as_deref().map(Cow::Borrowed),
@@ -261,6 +273,7 @@ impl TryFrom<CompactedItemWire<'_>> for CompactedItem {
message: item.message.into_owned(),
replacement_history,
guardian_history: item.guardian_history.map(Cow::into_owned),
retained_context: item.retained_context.map(Cow::into_owned),
mcp_resource_origins: item.mcp_resource_origins.map(Cow::into_owned),
window_number,
first_window_id: item.first_window_id.map(Cow::into_owned),
+14 -1
View File
@@ -194,6 +194,7 @@ fn compacted_replacement_history_stores_metadata_in_an_aligned_sidecar() -> Resu
},
ResponseItemEnvelope::new(compaction_item.clone()),
]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -304,6 +305,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() ->
let compacted_line = serde_json::to_value(RolloutItem::Compacted(CompactedItem {
message: "summary".to_string(),
replacement_history: Some(vec![envelope]),
retained_context: None,
guardian_history: Some(checkpoint.clone()),
mcp_resource_origins: Some(McpResourceOriginCheckpoint::default()),
window_number: None,
@@ -427,6 +429,15 @@ fn rollout_item_variants_preserve_existing_payload_shapes() -> Result<()> {
"type": "event_msg",
"payload": { "type": "warning", "message": "heads up" },
}),
json!({
"type": "retained_context",
"payload": {
"type": "verified_answer",
"turn_id": "turn-1",
"call_id": "ask-1",
"questions": [{"question": "Publish?", "answer": "Only privately."}],
},
}),
json!({
"type": "realtime_item",
"payload": {
@@ -451,7 +462,7 @@ fn rollout_item_variants_preserve_existing_payload_shapes() -> Result<()> {
fn rollout_item_schema_matches_tagged_payload_and_sibling_metadata() -> Result<()> {
let schema = serde_json::to_value(schemars::schema_for!(RolloutItem))?;
let variants = schema["oneOf"].as_array().expect("rollout variants");
assert_eq!(variants.len(), 11);
assert_eq!(variants.len(), 12);
for variant in variants {
let required = variant["required"].as_array().expect("required fields");
@@ -502,6 +513,7 @@ fn compacted_item_serializes_window_number_and_id() -> Result<()> {
let item = CompactedItem {
message: "summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(3),
@@ -540,6 +552,7 @@ fn compacted_item_migrates_legacy_numeric_window_id() -> Result<()> {
CompactedItem {
message: "summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(3),
+1
View File
@@ -420,6 +420,7 @@ mod job {
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::WorldState(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => None,
})
+1
View File
@@ -29,6 +29,7 @@ pub use codex_history::CompactedItem;
pub use codex_history::InitialHistory;
pub use codex_history::ResponseItemEnvelope;
pub use codex_history::ResumedHistory;
pub use codex_history::RetainedContextEvent;
pub use codex_history::RolloutItem;
pub use codex_history::RolloutLine;
pub(crate) use codex_protocol::protocol;
+5 -1
View File
@@ -1184,7 +1184,9 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
RolloutItem::TokenUsageRecord(_) => {
// Not included in `head`; skip.
}
RolloutItem::WorldState(_) | RolloutItem::SecurityRiskScore(_) => {
RolloutItem::RetainedContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::SecurityRiskScore(_) => {
// Not included in `head`; skip.
}
RolloutItem::RealtimeItem(_) => {
@@ -1262,6 +1264,7 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Va
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => {}
}
@@ -1317,6 +1320,7 @@ pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine>
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => {}
}
+2
View File
@@ -78,6 +78,7 @@ pub fn builder_from_items(
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => None,
}) && let Some(builder) = builder_from_session_meta(session_meta, rollout_path)
@@ -169,6 +170,7 @@ pub async fn extract_metadata_from_rollout(
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => None,
}),
+1
View File
@@ -291,6 +291,7 @@ fn builder_from_items_falls_back_to_filename() {
let items = vec![RolloutItem::Compacted(CompactedItem {
message: "noop".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
+1
View File
@@ -160,6 +160,7 @@ impl ModelContextScan {
| RolloutItem::SessionMeta(_)
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_) => {}
}
@@ -237,6 +237,7 @@ fn rollout_item_type(item: &RolloutItem) -> String {
RolloutItem::TurnContext(_) => "turn_context".to_string(),
RolloutItem::TokenUsageRecord(_) => "token_usage_record".to_string(),
RolloutItem::WorldState(_) => "world_state".to_string(),
RolloutItem::RetainedContext(_) => "retained_context".to_string(),
RolloutItem::SecurityRiskScore(_) => "security_risk_score".to_string(),
RolloutItem::RealtimeItem(item) => match &item.content {
codex_protocol::realtime::RealtimeItemContent::RealtimeSessionStarted => {
+1
View File
@@ -19,6 +19,7 @@ pub fn is_persisted_rollout_item(item: &RolloutItem, history_mode: ThreadHistory
| RolloutItem::TurnContext(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::SessionMeta(_) => true,
}
+1
View File
@@ -2099,6 +2099,7 @@ async fn resume_candidate_matches_cwd(
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => None,
})
+1
View File
@@ -293,6 +293,7 @@ fn conversation_text_from_item(item: &RolloutItem) -> Option<String> {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::Compacted(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_) => None,
+2 -1
View File
@@ -26,7 +26,7 @@ pub fn apply_rollout_item(
| RolloutItem::InterAgentCommunicationMetadata { .. } => {}
RolloutItem::Compacted(_) => {}
RolloutItem::WorldState(_) => {}
RolloutItem::SecurityRiskScore(_) => {}
RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => {}
RolloutItem::RealtimeItem(_) => {}
RolloutItem::TokenUsageRecord(_) => {}
}
@@ -56,6 +56,7 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::Compacted(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::WorldState(_) => false,
+1
View File
@@ -1322,6 +1322,7 @@ pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option<String> {
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::EventMsg(_) => None,
@@ -759,6 +759,7 @@ fn compacted(message: &str, replacement_history: Option<Vec<ResponseItem>>) -> R
message: message.to_string(),
replacement_history: replacement_history
.map(|items| items.into_iter().map(Into::into).collect()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
@@ -294,6 +294,7 @@ impl LegacyRolloutCanonicalizer {
| RolloutItem::TurnContext(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::WorldState(_)) => {
self.write_item(writer, &timestamp, item).await?;
@@ -86,6 +86,7 @@ pub(super) struct RollbackPlanner {
pending_user_response: Option<PendingUserResponse>,
pending_delivery_boundary: Option<usize>,
turn_boundaries: HashMap<String, usize>,
retained_fact_sources: Vec<(usize, String)>,
compactions: Vec<CompactionFrame>,
model_replay: ModelReplayPlanner,
}
@@ -102,6 +103,7 @@ impl RollbackPlanner {
pending_user_response: None,
pending_delivery_boundary: None,
turn_boundaries: HashMap::new(),
retained_fact_sources: Vec::new(),
compactions: Vec::new(),
model_replay: ModelReplayPlanner::new(),
}
@@ -223,6 +225,13 @@ impl RollbackPlanner {
self.assign_targeted_record(index, Some(record.turn_id.as_str()));
}
RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) => {}
RolloutItem::RetainedContext(codex_rollout::RetainedContextEvent::VerifiedAnswer(
answer,
)) => {
self.assign_targeted_record(index, Some(&answer.turn_id));
self.retained_fact_sources
.push((index, answer.turn_id.clone()));
}
RolloutItem::SecurityRiskScore(_) => self.record_boundaries[index] = None,
}
@@ -235,12 +244,32 @@ impl RollbackPlanner {
boundary_alive,
compactions,
model_replay,
turn_boundaries,
retained_fact_sources,
..
} = self;
let replay_anchor = model_replay.finish().empty_replacement_history_compaction;
let removed_turns = turn_boundaries
.iter()
.filter(|(_, boundary)| !boundary_alive[**boundary])
.map(|(turn_id, _)| turn_id.as_str())
.chain(
retained_fact_sources
.iter()
.filter(|(index, _)| {
record_boundaries[*index].is_some_and(|boundary| !boundary_alive[boundary])
})
.map(|(_, turn_id)| turn_id.as_str()),
)
.collect::<Vec<_>>();
let compacted_items = compactions
.into_iter()
.filter_map(|mut frame| {
// Migration removes rollback markers. Checkpoints must therefore carry
// the same surviving facts as their standalone source events.
if let Some(context) = &mut frame.item.retained_context {
context.remove_turns(&removed_turns);
}
if Some(frame.record_index) == replay_anchor {
frame.item.replacement_history = Some(Vec::new());
frame.item.mcp_resource_origins = None;
@@ -77,6 +77,7 @@ impl ModelReplayPlanner {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::WorldState(_) => return,
};
@@ -231,6 +231,7 @@ fn compacted(replacement_history: Vec<ResponseItem>) -> RolloutItem {
RolloutItem::Compacted(CompactedItem {
message: "checkpoint".to_string(),
replacement_history: Some(replacement_history.into_iter().map(Into::into).collect()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
@@ -1076,6 +1077,23 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
turns: vec!["keep-before-compaction".to_string()],
current_turn_id: Some("keep-before-compaction".to_string()),
});
let answer_event = serde_json::from_value(serde_json::json!({
"type": "verified_answer", "turn_id": "keep-before-compaction", "call_id": "ask-1",
"questions": [{"question": "Publish?", "answer": "Only privately."}]
}))
.expect("verified answer fixture");
let wake_answer_event = serde_json::from_value(serde_json::json!({
"type": "verified_answer", "turn_id": "empty-answer-turn", "call_id": "ask-2",
"questions": [{"question": "Continue?", "answer": "Keep it private."}]
}))
.expect("empty-turn answer fixture");
checkpoint.retained_context = Some(Default::default());
let context = checkpoint
.retained_context
.as_mut()
.expect("retained context");
context.record(&answer_event);
context.record(&wake_answer_event);
let path = write_rollout(
home.path(),
thread_id,
@@ -1083,7 +1101,11 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
vec![
started("keep-before-compaction"),
user_message("old question"),
RolloutItem::RetainedContext(answer_event),
completed("keep-before-compaction"),
started("empty-answer-turn"),
RolloutItem::RetainedContext(wake_answer_event),
completed("empty-answer-turn"),
RolloutItem::Compacted(checkpoint),
started("remove-after-compaction"),
user_message("new question"),
@@ -1113,7 +1135,13 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
.await
.expect("read rollback-through-compaction turns");
assert_eq!(turns.turns.len(), 1);
let checkpoint = read_rollout(&path)
let migrated = read_rollout(&path);
assert!(
!migrated
.iter()
.any(|line| matches!(line.item, RolloutItem::RetainedContext(_)))
);
let checkpoint = migrated
.into_iter()
.find_map(|line| match line.item {
RolloutItem::Compacted(item) => Some(item),
@@ -1122,6 +1150,14 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
.expect("retained compaction");
assert_eq!(checkpoint.replacement_history, Some(Vec::new()));
assert_eq!(checkpoint.mcp_resource_origins, None);
assert_eq!(
checkpoint
.retained_context
.expect("retained context checkpoint")
.verified_answers()
.count(),
0
);
}
#[tokio::test]
@@ -1410,6 +1446,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() {
RolloutItem::Compacted(CompactedItem {
message: "superseded checkpoint".repeat(1024),
replacement_history: Some(Vec::new()),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(1),
@@ -1433,6 +1470,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() {
}
.into(),
]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: Some(2),
@@ -306,6 +306,7 @@ impl ThreadMetadataSync {
| RolloutItem::Compacted(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::WorldState(_) => {}
}
@@ -583,6 +584,7 @@ mod tests {
let item = RolloutItem::Compacted(CompactedItem {
message: "compacted".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -74,6 +74,7 @@ fn legacy_transcript_preview_scans_tail_across_compaction() {
RolloutItem::Compacted(CompactedItem {
message: String::from("summary that is not transcript text"),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -208,6 +209,7 @@ fn legacy_transcript_preview_falls_back_for_oversized_hidden_record() {
RolloutItem::Compacted(CompactedItem {
message: "x".repeat(MAX_LEGACY_TRANSCRIPT_PREVIEW_SCAN_BYTES),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
@@ -238,6 +240,7 @@ fn legacy_transcript_preview_falls_back_when_scan_budget_is_exhausted() {
let compacted = RolloutItem::Compacted(CompactedItem {
message: "x".repeat(MAX_LEGACY_TRANSCRIPT_PREVIEW_SCAN_BYTES / 8),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,