From 5971d42847aae04db0e3c70146e0b189fc9a6803 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Wed, 2 Sep 2026 14:48:52 +0000 Subject: [PATCH] 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 --- codex-rs/Cargo.lock | 1 + .../app-server-exports-stable.json.zst | Bin 145918 -> 146321 bytes .../src/protocol/thread_history.rs | 2 + .../src/protocol/thread_history_projection.rs | 1 + .../thread_history_projection_tests.rs | 1 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 6 +- codex-rs/core/src/agent/control_tests.rs | 17 + codex-rs/core/src/context_manager/history.rs | 42 ++- .../core/src/context_manager/history_tests.rs | 17 +- codex-rs/core/src/session/mod.rs | 4 + .../src/session/rollout_reconstruction.rs | 12 + .../session/rollout_reconstruction_tests.rs | 26 ++ codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 7 + .../tests/suite/guardian_retained_context.rs | 330 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/ext/extension-api/Cargo.toml | 1 + .../src/capabilities/conversation_history.rs | 7 + .../src/sessions/append.rs | 2 + codex-rs/history/Cargo.toml | 2 +- codex-rs/history/src/lib.rs | 9 + codex-rs/history/src/retained_context.rs | 135 +++++++ .../history/src/retained_context_tests.rs | 64 ++++ codex-rs/history/src/rollout_payload.rs | 13 + codex-rs/history/src/tests.rs | 15 +- codex-rs/memories/write/src/phase1.rs | 1 + codex-rs/rollout/src/lib.rs | 1 + codex-rs/rollout/src/list.rs | 6 +- codex-rs/rollout/src/metadata.rs | 2 + codex-rs/rollout/src/metadata_tests.rs | 1 + codex-rs/rollout/src/model_context.rs | 1 + codex-rs/rollout/src/persistence_metrics.rs | 1 + codex-rs/rollout/src/policy.rs | 1 + codex-rs/rollout/src/recorder.rs | 1 + codex-rs/rollout/src/search.rs | 1 + codex-rs/state/src/extract.rs | 3 +- codex-rs/state/src/runtime/threads.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../local/rollout_migration/canonicalizer.rs | 1 + .../local/rollout_migration/rollback_plan.rs | 29 ++ .../rollout_migration/rollback_replay.rs | 1 + .../src/local/rollout_migration_tests.rs | 40 ++- .../thread-store/src/thread_metadata_sync.rs | 2 + .../resume_picker_transcript_preview_tests.rs | 3 + 45 files changed, 805 insertions(+), 9 deletions(-) create mode 100644 codex-rs/core/tests/suite/guardian_retained_context.rs create mode 100644 codex-rs/history/src/retained_context.rs create mode 100644 codex-rs/history/src/retained_context_tests.rs 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 485dfcd21118a3adbc0d95126bfae25437a02a8c..f9ad24af2765e6299b0ed2315f1882ae173fc173 100644 GIT binary patch delta 16834 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 BBvJqX delta 16428 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 Jhg|`awE*85hM)id 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,