diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9685f5f1a6..aafac77717 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3183,6 +3183,7 @@ dependencies = [ "codex-config", "codex-context-fragments", "codex-exec-server-protocol", + "codex-history", "codex-mcp", "codex-protocol", "codex-tools", 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 485dfcd211..f9ad24af27 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 028069a0f5..965a30d2d0 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs index 7fc8648b32..f5a1e238f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs @@ -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(), } diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs index a6d0077770..f4abca35cf 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a6f728deb1..54ffa3adc6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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), diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index e5518b2729..39c7b312c6 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -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 diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index a7acf93169..0e787e122c 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -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, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index bd7429e62b..f6b459261c 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -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>, /// Starts at the first compaction; ordinary history snapshots need no second payload copy. review_history: Option, + /// Host facts independent of the model window; snapshots share immutable state. + retained_context: Arc, /// 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>, review_history: Option, + retained_context: Arc, 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 + 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 { self.review_history .as_ref() @@ -379,6 +407,7 @@ impl ContextManager { } pub(crate) fn replace_annotated(&mut self, items: Vec) { + 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::>(); + 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; } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 7e732f776c..2e40a05a28 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -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] diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 15be7e485d..7d61a5523b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1540,6 +1540,7 @@ impl Session { ) -> Option { 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())); diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 786e26c2d1..a54c08520f 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -10,6 +10,7 @@ use uuid::Uuid; #[derive(Debug)] pub(super) struct RolloutReconstruction { pub(super) history: Vec, + pub(super) retained_context: codex_history::RetainedContext, pub(super) guardian_history: Option, pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, @@ -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, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index f6a86d5f08..93492d9f5c 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -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, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6d1953e3a2..a85d6cfe2d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1369,6 +1369,7 @@ impl Session { | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => {} } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b5931b330e..21c838666b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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), diff --git a/codex-rs/core/tests/suite/guardian_retained_context.rs b/codex-rs/core/tests/suite/guardian_retained_context.rs new file mode 100644 index 0000000000..2ef4f593b3 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_retained_context.rs @@ -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 { + 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> { + 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> { + 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 = 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::>(), + 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![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::>(); + 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(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 8d43ca03ef..a31883290b 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -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"))] diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index c6eb90ec97..a0d4fe1f4c 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -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 } diff --git a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs index 5751d9e9e8..7e8ff75031 100644 --- a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs +++ b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs @@ -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 + 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 + Send + '_> { diff --git a/codex-rs/external-agent-migration/src/sessions/append.rs b/codex-rs/external-agent-migration/src/sessions/append.rs index 04dd151279..70b4b30e94 100644 --- a/codex-rs/external-agent-migration/src/sessions/append.rs +++ b/codex-rs/external-agent-migration/src/sessions/append.rs @@ -244,6 +244,7 @@ fn source_model_items(items: &[RolloutItem]) -> Option>> | 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> { | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::TokenUsageRecord(_) | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) => {} RolloutItem::ResponseItem(response_item) => model_items.push(&response_item.item), RolloutItem::EventMsg( diff --git a/codex-rs/history/Cargo.toml b/codex-rs/history/Cargo.toml index 6188303226..bd938d2c2e 100644 --- a/codex-rs/history/Cargo.toml +++ b/codex-rs/history/Cargo.toml @@ -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 } diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 069a243fe4..10dbde0af8 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -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>, pub guardian_history: Option, + pub retained_context: Option, pub mcp_resource_origins: Option, pub window_number: Option, pub first_window_id: Option, @@ -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, diff --git a/codex-rs/history/src/retained_context.rs b/codex-rs/history/src/retained_context.rs new file mode 100644 index 0000000000..3214212a0c --- /dev/null +++ b/codex-rs/history/src/retained_context.rs @@ -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, +} + +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, + /// 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 { + 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; diff --git a/codex-rs/history/src/retained_context_tests.rs b/codex-rs/history/src/retained_context_tests.rs new file mode 100644 index 0000000000..baa34b748f --- /dev/null +++ b/codex-rs/history/src/retained_context_tests.rs @@ -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." + ); +} diff --git a/codex-rs/history/src/rollout_payload.rs b/codex-rs/history/src/rollout_payload.rs index 7939b493e3..0a491fed6d 100644 --- a/codex-rs/history/src/rollout_payload.rs +++ b/codex-rs/history/src/rollout_payload.rs @@ -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> 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>, #[serde(default, skip_serializing_if = "Option::is_none")] + retained_context: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] mcp_resource_origins: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] window_number: Option, @@ -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> 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), diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 18fca26a7c..703a3eaa66 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -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), diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index f4724201ce..92bd4ad782 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -420,6 +420,7 @@ mod job { | RolloutItem::TokenUsageRecord(_) | RolloutItem::RealtimeItem(_) | RolloutItem::WorldState(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => None, }) diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index 20f966ae90..70abb519c7 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -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; diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index d54aa44a82..8805d6433c 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1184,7 +1184,9 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { // 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 {} } @@ -1317,6 +1320,7 @@ pub async fn read_session_meta_line(path: &Path) -> io::Result | RolloutItem::TokenUsageRecord(_) | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => {} } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index f76652329d..727bff87f4 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -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, }), diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 867d7565b8..cefbe322a0 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -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, diff --git a/codex-rs/rollout/src/model_context.rs b/codex-rs/rollout/src/model_context.rs index f5328ca608..3ea48b8138 100644 --- a/codex-rs/rollout/src/model_context.rs +++ b/codex-rs/rollout/src/model_context.rs @@ -160,6 +160,7 @@ impl ModelContextScan { | RolloutItem::SessionMeta(_) | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) => {} } diff --git a/codex-rs/rollout/src/persistence_metrics.rs b/codex-rs/rollout/src/persistence_metrics.rs index ca046e1f61..99c6e2ffda 100644 --- a/codex-rs/rollout/src/persistence_metrics.rs +++ b/codex-rs/rollout/src/persistence_metrics.rs @@ -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 => { diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index cc54ac5554..892f5678c9 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -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, } diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index aa08d4a733..f6c56f129e 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -2099,6 +2099,7 @@ async fn resume_candidate_matches_cwd( | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::EventMsg(_) => None, }) diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index cbfd54de8c..152050f662 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -293,6 +293,7 @@ fn conversation_text_from_item(item: &RolloutItem) -> Option { | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::Compacted(_) | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::WorldState(_) => None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 94370044b8..eaf670e2ce 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -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, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index a18c051d8e..f5fea4a5de 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1322,6 +1322,7 @@ pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { | RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::EventMsg(_) => None, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index 2891baacf8..23dfb7d0ba 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -759,6 +759,7 @@ fn compacted(message: &str, replacement_history: Option>) -> 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), diff --git a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs index 0607411c6e..13a80e37f8 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs @@ -294,6 +294,7 @@ impl LegacyRolloutCanonicalizer { | RolloutItem::TurnContext(_) | RolloutItem::TokenUsageRecord(_) | RolloutItem::RealtimeItem(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::WorldState(_)) => { self.write_item(writer, ×tamp, item).await?; 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 f015caaa32..58d1944407 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 @@ -86,6 +86,7 @@ pub(super) struct RollbackPlanner { pending_user_response: Option, pending_delivery_boundary: Option, turn_boundaries: HashMap, + retained_fact_sources: Vec<(usize, String)>, compactions: Vec, 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::>(); 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; diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs index 80dfdbcac7..ac23e9c577 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_replay.rs @@ -77,6 +77,7 @@ impl ModelReplayPlanner { | RolloutItem::InterAgentCommunicationMetadata { .. } | RolloutItem::RealtimeItem(_) | RolloutItem::TokenUsageRecord(_) + | RolloutItem::RetainedContext(_) | RolloutItem::SecurityRiskScore(_) | RolloutItem::WorldState(_) => return, }; 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 0440f38f2c..3ffc7aa5ef 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -231,6 +231,7 @@ fn compacted(replacement_history: Vec) -> 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), diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 2fdadd96dd..4e28993645 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -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, diff --git a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs index d08b188ea2..61b99ed295 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs @@ -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,