From 0c3a14bbc20e1b55286d3fb89a3235c1e11d9cc1 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Tue, 15 Sep 2026 19:59:09 +0000 Subject: [PATCH] Preserve Guardian authorization evidence across checkpoint migration (#45782) ## Why Older compaction checkpoints can lack a producer model hash. Enabling thread-owned Guardian context must accommodate those checkpoints without losing user instructions or verified answers. ## What changed - Select review policy from each history snapshot independently of the session's evidence capture policy. - Keep legacy review for checkpoints with missing or empty producer hashes when the transcript preserves the evidence. Preserve strict compatibility checks when review depends on retained-only evidence. - Continue capturing retained instructions and answers during migration, while supplying legacy reviews with runtime answers and subagent reviews with root authorization evidence. - Record producer model hashes on new compactions regardless of context mode. ## Testing Add coverage for checkpoint migration across compaction and resume in both history storage modes, snapshot policy stability, retained-evidence preservation, and authorization revalidation for owning sessions and subagents. GitOrigin-RevId: ec72b4a51d40729173e24ac1b53b7d516ddaa825 --- .../suite/v2/guardian_v2_history_tests.rs | 13 +- .../src/agent/control/user_authorization.rs | 21 +- .../core/src/context/guardian_context_mode.rs | 38 ++- .../src/context/guardian_review_evidence.rs | 27 +- codex-rs/core/src/context_manager/history.rs | 61 ++++- .../core/src/context_manager/history_tests.rs | 103 ++++++- codex-rs/core/src/guardian/review_session.rs | 8 +- .../core/src/guardian/review_session_setup.rs | 7 +- codex-rs/core/src/session/mod.rs | 15 +- codex-rs/core/src/session/session.rs | 6 +- .../src/tools/handlers/request_user_input.rs | 6 +- .../handlers/request_user_input_tests.rs | 2 +- .../tests/suite/guardian_authorization.rs | 31 ++- .../guardian_checkpoint_migration_tests.rs | 257 ++++++++++++++++++ .../suite/guardian_subagent_authorization.rs | 34 ++- codex-rs/core/tests/suite/mod.rs | 2 + .../guardian-v2/src/async_scorer/approval.rs | 7 +- .../src/async_scorer/budget_tests.rs | 1 + .../src/async_scorer/extension_tests.rs | 8 + .../src/async_scorer/observation.rs | 2 +- 20 files changed, 564 insertions(+), 85 deletions(-) create mode 100644 codex-rs/core/tests/suite/guardian_checkpoint_migration_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs index 6f934fab44..4d50196fcc 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs @@ -97,7 +97,8 @@ enum ReviewCheckpoint { #[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some("matching"), Some("different"), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "incompatible checkpoint")] #[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some("matching"), None, 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "unknown Luna compatibility")] #[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some("matching"), Some(""), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "empty Luna compatibility")] -#[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, None, Some("matching"), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "unknown producer remains unknown after model switch")] +#[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, None, Some("matching"), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "unknown producer preserves retained evidence")] +#[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some(""), Some("matching"), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "empty producer preserves retained evidence")] #[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some("matching"), Some("matching"), 140, EvidenceSize::Normal, ReviewCheckpoint::Valid; "source call evicted")] #[test_case(ContextPath::ThreadOwned, CheckpointReuse::Enabled, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedAnswer, ReviewCheckpoint::Valid; "incomplete answers reject fresh low score")] #[test_case(ContextPath::Legacy, CheckpointReuse::Enabled, Some("matching"), Some("matching"), 0, EvidenceSize::Normal, ReviewCheckpoint::Valid; "legacy answers remain runtime only")] @@ -398,7 +399,7 @@ async fn guardians_retain_evidence_after_compaction_and_resume( .await?; classifier.allow_luna.notify_one(); } - if reject_sync_checkpoint && (1..=2).contains(&index) { + if reject_sync_checkpoint && index > 0 { let notification = timeout( TIMEOUT, app_server.read_stream_until_matching_notification( @@ -439,7 +440,7 @@ async fn guardians_retain_evidence_after_compaction_and_resume( let completed: TurnCompletedNotification = timeout(TIMEOUT, app_server.read_notification("turn/completed")).await??; assert_eq!(completed.turn.status, TurnStatus::Completed); - if reject_sync_checkpoint && (1..=2).contains(&index) { + if reject_sync_checkpoint && index > 0 { // The first review established a cached session before compaction. Neither // that session nor a new one may review unusable evidence, including after resume. let reviews = review_requests.lock().expect("request log lock"); @@ -778,10 +779,8 @@ async fn guardians_retain_evidence_after_compaction_and_resume( .metadata .as_ref() .and_then(|metadata| metadata.compaction_model_hash.as_deref()), - matches!(context_path, ContextPath::ThreadOwned) - .then_some(parent_hash) - .flatten(), - "only the enabled path records checkpoint producer provenance", + parent_hash, + "every compaction records its producer provenance", ); } } diff --git a/codex-rs/core/src/agent/control/user_authorization.rs b/codex-rs/core/src/agent/control/user_authorization.rs index cec448fecc..b0084b6661 100644 --- a/codex-rs/core/src/agent/control/user_authorization.rs +++ b/codex-rs/core/src/agent/control/user_authorization.rs @@ -1,5 +1,5 @@ -//! Projects bounded root evidence for worker reviewers using the thread's context mode. -//! Legacy mode keeps parent-window selection; retained mode preserves original source scope. +//! Projects bounded root evidence for worker reviewers using the session's capture policy. +//! Retained root instructions stay authoritative while old checkpoints use legacy review. //! Projection limits do not change authorization completeness; unavailable source text does. //! Retained-history reconciliation owns recovery order and missing-instruction provenance. @@ -52,12 +52,13 @@ impl AgentControl { .services .thread_extension_data .get_or_init(GuardianReviewEvidence::default); + let context_mode = root_thread.session.guardian_context_mode; let mut latest_user_turn_id = None; - let (messages, authorization_version) = if root_evidence.context_mode() - == GuardianContextMode::ThreadOwned + let (messages, authorization_version) = if context_mode == GuardianContextMode::ThreadOwned { + let retained_context = root_history.retained_context(); let reconciled = ReconciledRetainedContext::new( - history.retained_context(), + Some(retained_context), root_history .annotated_items() .iter() @@ -158,6 +159,12 @@ impl AgentControl { assistant_messages.drain(..assistant_messages.len().saturating_sub(available)); messages.extend(assistant_messages); let mut authorization_version = root_evidence.authorization_version(history.as_ref()); + if history.retained_context().is_none() { + // Legacy checkpoint review hides retained answers from its snapshot, but + // root authorization still needs their completeness and original restrictions. + authorization_version.retained_context_complete = + codex_guardian_context::render_verified_answers(retained_context).complete; + } if !authorization_version.retained_context_complete { messages.insert( /*index*/ 0, @@ -174,8 +181,8 @@ impl AgentControl { messages.insert(/*index*/ 0, GuardianRootMessage::RetainedContextScope); (messages, authorization_version) } else { - let mut messages = root_history - .raw_items() + let mut messages = history + .review_items() .filter_map(|item| match (parse_turn_item(item), item) { (Some(TurnItem::UserMessage(message)), _) => { let message = message.message(); diff --git a/codex-rs/core/src/context/guardian_context_mode.rs b/codex-rs/core/src/context/guardian_context_mode.rs index 0b9cf655ae..fc0bb77a7a 100644 --- a/codex-rs/core/src/context/guardian_context_mode.rs +++ b/codex-rs/core/src/context/guardian_context_mode.rs @@ -1,10 +1,13 @@ -//! Immutable session mode shared by history, evidence projection, and reviewer policy. -//! Resolve the rollout flag at session construction, not independently at each consumer. +//! Session capture policy and the reviewer policy carried by each history snapshot. +//! Older checkpoints keep legacy review while its transcript preserves the retained evidence. +use codex_extension_api::ConversationHistorySnapshot; use codex_features::Feature; use codex_features::Features; +use codex_history::ResponseItemEnvelope; +use codex_protocol::models::ResponseItem; -/// Selects legacy compatibility or thread-owned evidence for a session's lifetime. +/// Selects legacy compatibility or thread-owned evidence. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum GuardianContextMode { #[default] @@ -13,6 +16,15 @@ pub enum GuardianContextMode { } impl GuardianContextMode { + /// Read reviewer policy from the same snapshot as its evidence, including delayed reviews. + pub fn from_history(history: &dyn ConversationHistorySnapshot) -> Self { + if history.retained_context().is_some() { + Self::ThreadOwned + } else { + Self::Legacy + } + } + pub(crate) fn from_features(features: &Features) -> Self { if features.enabled(Feature::GuardianThreadContext) { Self::ThreadOwned @@ -20,4 +32,24 @@ impl GuardianContextMode { Self::Legacy } } + + pub(crate) fn for_checkpoint(self, items: &[ResponseItemEnvelope]) -> Self { + let checkpoint = items.iter().rev().find(|envelope| { + matches!( + envelope.item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + }); + if checkpoint.is_some_and(|checkpoint| { + checkpoint + .metadata + .as_ref() + .and_then(|metadata| metadata.compaction_model_hash.as_deref()) + .is_none_or(str::is_empty) + }) { + Self::Legacy + } else { + self + } + } } diff --git a/codex-rs/core/src/context/guardian_review_evidence.rs b/codex-rs/core/src/context/guardian_review_evidence.rs index d1d8cad2a2..142ca5a0d5 100644 --- a/codex-rs/core/src/context/guardian_review_evidence.rs +++ b/codex-rs/core/src/context/guardian_review_evidence.rs @@ -1,7 +1,6 @@ -//! Selects Guardian answer evidence once per thread and retains completed reviews. -//! The temporary legacy mode preserves its bounded runtime buffer; thread-owned -//! mode reads retained answers from history. Capture uses the same thread feature setting; -//! legacy mode does not produce new retained-answer events. +//! Selects Guardian answer evidence from the review's history snapshot and retains reviews. +//! Legacy review uses a bounded runtime buffer while thread-owned review uses retained answers. +//! A migrating session captures both until its checkpoint can support thread-owned review. use std::collections::BTreeSet; use std::collections::VecDeque; @@ -43,7 +42,6 @@ pub struct GuardianUserInputSnapshot { /// completed reviews remain thread-local, and authorization changes invalidate stale records. #[derive(Debug, Default)] pub struct GuardianReviewEvidence { - mode: GuardianContextMode, state: Mutex, } @@ -57,26 +55,15 @@ struct GuardianReviewEvidenceState { } impl GuardianReviewEvidence { - /// Reports the fixed thread mode used for both capture and reviewer policy. - pub fn context_mode(&self) -> GuardianContextMode { - self.mode - } - - pub(crate) fn new(mode: GuardianContextMode) -> Self { - Self { - mode, - state: Mutex::default(), - } - } - /// Preserves the legacy capture limits before hooks can replace the tool output. pub(crate) fn record_user_input( &self, + history: &dyn ConversationHistorySnapshot, call_id: &str, questions: &[RequestUserInputQuestion], response: &RequestUserInputResponse, ) { - if !matches!(self.mode, GuardianContextMode::Legacy) { + if GuardianContextMode::from_history(history) != GuardianContextMode::Legacy { return; } let fragment = questions @@ -128,7 +115,7 @@ impl GuardianReviewEvidence { &self, history: &dyn ConversationHistorySnapshot, ) -> GuardianUserInputSnapshot { - match self.mode { + match GuardianContextMode::from_history(history) { GuardianContextMode::ThreadOwned => { let answers = history .retained_context() @@ -181,7 +168,7 @@ impl GuardianReviewEvidence { history: &dyn ConversationHistorySnapshot, call_id: &str, ) -> Option { - match self.mode { + match GuardianContextMode::from_history(history) { GuardianContextMode::ThreadOwned => history .retained_context()? .verified_answers() diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4617e462ab..ffbcaead2e 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -33,6 +33,7 @@ use codex_history::CodexHarnessMetadata; use codex_history::GuardianHistoryCheckpoint; use codex_history::ResponseItemEnvelope; use codex_history::RetainedContext; +use codex_history::RetainedContextEntry; use codex_history::RetainedContextEvent; use codex_history::RetainedInputSource; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; @@ -78,8 +79,10 @@ pub(crate) struct ContextManager { review_history: Option, /// Host facts independent of the model window; snapshots share immutable state. retained_context: Arc, - /// Capture, replay, and snapshot selection share the immutable session mode. + /// Capture follows the session flag, including while an older checkpoint uses legacy review. guardian_context_mode: GuardianContextMode, + /// Reviewer policy travels with the history snapshot, independently of capture. + guardian_review_mode: GuardianContextMode, retain_inherited_user_messages: bool, /// Bumped whenever history is rewritten, such as compaction or rollback. history_version: u64, @@ -181,6 +184,7 @@ impl ContextManager { review_history: None, retained_context: Arc::default(), guardian_context_mode: GuardianContextMode::Legacy, + guardian_review_mode: GuardianContextMode::Legacy, retain_inherited_user_messages: false, history_version: 0, reset_version: 0, @@ -198,7 +202,7 @@ impl ContextManager { items: Arc::clone(&self.items), review_history: self.review_history.clone(), retained_context: Arc::clone(&self.retained_context), - guardian_context_mode: self.guardian_context_mode, + guardian_context_mode: self.guardian_review_mode, history_version: self.history_version, user_message_revision: self.user_message_revision, }) @@ -214,6 +218,7 @@ impl ContextManager { ) -> Self { Self { guardian_context_mode, + guardian_review_mode: guardian_context_mode, retain_inherited_user_messages: guardian_context_mode == GuardianContextMode::ThreadOwned && !source.is_non_root_agent(), @@ -234,7 +239,7 @@ impl ContextManager { } pub(crate) fn guardian_history_checkpoint(&self) -> Option { - if self.guardian_context_mode == GuardianContextMode::ThreadOwned { + if self.guardian_review_mode == GuardianContextMode::ThreadOwned { return None; } self.review_history @@ -247,8 +252,53 @@ impl ContextManager { retained_context: Option<&RetainedContext>, checkpoint: Option<&GuardianHistoryCheckpoint>, ) { + // Replay captures ordinary messages too. Legacy review can keep using those messages + // while their original text survives in its selected transcript. Retained-only facts + // and missing-answer markers still require strict checkpoint compatibility. + let has_retained_only_evidence = retained_context.is_some_and(|context| { + !context.verified_answers_complete() + || context.ordered_entries().any(|(_, entry)| match entry { + RetainedContextEntry::VerifiedAnswer(_) => true, + RetainedContextEntry::UserMessage(message) => { + let contains_message = |item: &ResponseItem| { + if item.id().map(codex_protocol::ResponseItemId::as_str) + != message.message_id.as_deref() + || item.turn_id().unwrap_or_default() != message.turn_id + { + return false; + } + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" || is_contextual_user_message_content(content) { + return false; + } + let text = content + .iter() + .filter_map(|content| match content { + ContentItem::InputText { text } + | ContentItem::OutputText { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + guardian_truncate_text(&text, GUARDIAN_MAX_ROOT_MESSAGE_TOKENS).0 + == message.text + }; + !checkpoint.map_or_else( + || self.raw_items().any(contains_message), + |checkpoint| checkpoint.0.iter().any(contains_message), + ) + } + }) + }); + self.guardian_review_mode = if has_retained_only_evidence { + self.guardian_context_mode + } else { + self.guardian_context_mode.for_checkpoint(&self.items) + }; self.restore_retained_context(retained_context); - if self.guardian_context_mode == GuardianContextMode::ThreadOwned { + if self.guardian_review_mode == GuardianContextMode::ThreadOwned { // Older retained checkpoints cleared oversized instructions. Recover their // bounded root excerpts before discarding the legacy source transcript. let items = &self.items; @@ -507,8 +557,7 @@ impl ContextManager { /// Compaction changes the model's history without changing the user's authorization. pub(crate) fn replace_compacted(&mut self, items: Vec) { - if self.guardian_context_mode == GuardianContextMode::Legacy - && self.review_history.is_none() + if self.guardian_review_mode == GuardianContextMode::Legacy && self.review_history.is_none() { let mut retained = TranscriptHistory::new(self.history_version.saturating_add(1)); for item in self.raw_items().filter(|item| { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index e0844511bd..eaaa28bb29 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -154,8 +154,11 @@ fn conversation_history_snapshot_shares_response_items_until_history_changes() { ); } -#[test] -fn conversation_history_snapshot_binds_compaction_hash_to_the_latest_item() { +#[test_case(None; "missing hash")] +#[test_case(Some(""); "empty hash")] +fn conversation_history_snapshot_binds_review_mode_and_hash_to_the_latest_item( + latest_hash: Option<&str>, +) { let checkpoint = ResponseItemEnvelope { item: serde_json::from_value(serde_json::json!({ "type": "compaction", "id": "known", "encrypted_content": "opaque checkpoint" @@ -166,28 +169,51 @@ fn conversation_history_snapshot_binds_compaction_hash_to_the_latest_item() { ..Default::default() }), }; - let mut history = ContextManager::new(); + let mut history = ContextManager::with_guardian_context_mode( + GuardianContextMode::ThreadOwned, + &codex_protocol::protocol::SessionSource::Cli, + ); history.replace_annotated(vec![checkpoint.clone()]); + history.restore_review_context(/*retained_context*/ None, /*checkpoint*/ None); let snapshot = history.conversation_history_snapshot(); let mut unknown = checkpoint.clone(); - unknown.metadata = None; + unknown.metadata = latest_hash.map(|hash| CodexHarnessMetadata { + compaction_model_hash: Some(hash.to_owned()), + ..Default::default() + }); unknown.item = serde_json::from_value(serde_json::json!({ "type": "compaction", "id": "unknown", "encrypted_content": "newer opaque checkpoint" })) .expect("unknown checkpoint fixture"); history.replace_annotated(vec![checkpoint.clone(), unknown]); + let mut legacy_context = RetainedContext::default(); + legacy_context.mark_user_messages_incomplete(); + history.restore_review_context(Some(&legacy_context), /*checkpoint*/ None); + assert_eq!( + GuardianContextMode::from_history(history.conversation_history_snapshot().as_ref()), + GuardianContextMode::Legacy, + ); assert_eq!( history .conversation_history_snapshot() .latest_compaction_model_hash(), - None + latest_hash ); assert_eq!( snapshot.latest_compaction_model_hash(), Some("producer-hash") ); + assert_eq!( + GuardianContextMode::from_history(snapshot.as_ref()), + GuardianContextMode::ThreadOwned, + ); // Checkpoint replay/rollback restores the item's own provenance, not a new model's metadata. history.replace_annotated(vec![checkpoint]); + history.restore_review_context(/*retained_context*/ None, /*checkpoint*/ None); + assert_eq!( + GuardianContextMode::from_history(history.conversation_history_snapshot().as_ref()), + GuardianContextMode::ThreadOwned, + ); assert_eq!( history .conversation_history_snapshot() @@ -196,6 +222,73 @@ fn conversation_history_snapshot_binds_compaction_hash_to_the_latest_item() { ); } +#[test_case(serde_json::json!({ + "verified_answers": [], "incomplete": true +}); "missing answers")] +#[test_case(serde_json::json!({ + "verified_answers": [], "incomplete": false, "next_order": 1, + "user_messages": [{ + "turn_id": "turn", "message_id": "restriction", + "text": "Only publish to a private repository.", "complete": true + }] +}); "retained instructions")] +fn checkpoint_retained_evidence_prevents_legacy_downgrade(saved_context: serde_json::Value) { + let mut history = ContextManager::with_guardian_context_mode( + GuardianContextMode::ThreadOwned, + &codex_protocol::protocol::SessionSource::Cli, + ); + history.replace_annotated(vec![ResponseItemEnvelope::new( + serde_json::from_value(serde_json::json!({ + "type": "compaction", "id": "unknown", "encrypted_content": "opaque checkpoint" + })) + .expect("checkpoint fixture"), + )]); + let retained: RetainedContext = + serde_json::from_value(saved_context).expect("retained evidence checkpoint"); + history.restore_review_context(Some(&retained), /*checkpoint*/ None); + + let snapshot = history.conversation_history_snapshot(); + assert_eq!( + GuardianContextMode::from_history(snapshot.as_ref()), + GuardianContextMode::ThreadOwned, + ); + assert_eq!(snapshot.retained_context(), Some(&retained)); + assert_eq!(snapshot.latest_compaction_model_hash(), None); +} + +#[test_case(None, GuardianContextMode::Legacy; "model window")] +#[test_case(Some("Only publish privately."), GuardianContextMode::Legacy; "legacy backup")] +#[test_case(Some("Only publish."), GuardianContextMode::ThreadOwned; "shortened legacy backup")] +fn checkpoint_replayed_instructions_keep_legacy_review_when_the_source_survives( + backup_text: Option<&str>, + expected_mode: GuardianContextMode, +) { + let mut history = ContextManager::with_guardian_context_mode( + GuardianContextMode::ThreadOwned, + &codex_protocol::protocol::SessionSource::Cli, + ); + history.replace_annotated(vec![ResponseItemEnvelope::new( + serde_json::from_value(serde_json::json!({ + "type": "compaction", "id": "unknown", "encrypted_content": "opaque checkpoint" + })) + .expect("checkpoint fixture"), + )]); + history.restore_review_context(/*retained_context*/ None, /*checkpoint*/ None); + history.record_items( + &[user_input_text_msg("Only publish privately.")], + TruncationPolicy::Bytes(10_000), + ); + let retained = history.retained_context().clone(); + let backup = backup_text.map(|text| GuardianHistoryCheckpoint(vec![user_input_text_msg(text)])); + history.restore_review_context(Some(&retained), backup.as_ref()); + + assert_eq!( + GuardianContextMode::from_history(history.conversation_history_snapshot().as_ref()), + expected_mode, + ); + assert_eq!(history.retained_context(), &retained); +} + #[test] fn conversation_history_snapshot_excludes_contextual_user_messages() { let contextual_message = crate::context::ContextualUserFragment::into(UserInstructions { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 8c1a9146ed..458ed9b970 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -469,10 +469,10 @@ async fn run_review_on_session( .sync_session_approved_hosts_to(&review_session.session.services.network_approval) .await; - let history = if params.parent_session.guardian_context_mode - == GuardianContextMode::ThreadOwned - { - params.parent_history.conversation_history_snapshot() + let parent_history = params.parent_history.conversation_history_snapshot(); + let history = if GuardianContextMode::from_history(parent_history.as_ref()) + == GuardianContextMode::ThreadOwned { + parent_history } else { params.parent_session.conversation_history_snapshot().await }; diff --git a/codex-rs/core/src/guardian/review_session_setup.rs b/codex-rs/core/src/guardian/review_session_setup.rs index edf191bfe0..41ead25c63 100644 --- a/codex-rs/core/src/guardian/review_session_setup.rs +++ b/codex-rs/core/src/guardian/review_session_setup.rs @@ -30,15 +30,16 @@ impl PreparedGuardianContext { if reset_version != history.reset_version { history_reset.cancel(); } - let context_policy = - ReviewContextPolicy::for_context(parent.guardian_context_mode, &config.features); + let context_mode = + GuardianContextMode::from_history(history.conversation_history_snapshot().as_ref()); + let context_policy = ReviewContextPolicy::for_context(context_mode, &config.features); let root_authorization_version = context_policy.root_authorization_version(&parent).await; let parent_compaction = context_policy.parent_compaction(history, compaction_model_hash)?; let mut key = GuardianReviewSessionReuseKey::from_spawn_config( &config, parent.inherited_instructions().await, history.history_version(), - parent.guardian_context_mode, + context_mode, ) .with_environments(context.environments()) .with_node_repl_policy_eligibility(context.model_info.computer_use_review_required()) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 65491108e5..1e8d511caa 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -24,7 +24,6 @@ use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::context::ContextualUserFragment; use crate::context::DeveloperInstructions; -use crate::context::GuardianContextMode; use crate::context::GuardianPolicy; use crate::context::ManagedDeveloperInstructions; use crate::context::ModelSwitchInstructions; @@ -4003,14 +4002,12 @@ impl Session { for envelope in &mut items { Self::assign_missing_response_item_id(&mut envelope.item); } - if self.guardian_context_mode == GuardianContextMode::ThreadOwned - && let Some(checkpoint) = items.iter_mut().rev().find(|envelope| { - matches!( - envelope.item, - ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } - ) - }) - { + if let Some(checkpoint) = items.iter_mut().rev().find(|envelope| { + matches!( + envelope.item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + }) { checkpoint .metadata .get_or_insert_default() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7503f81984..83e6024ce6 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -961,11 +961,9 @@ impl Session { thread_id.to_string(), thread_extension_init, ); - // Resolve once for live history, replay, and all reviewer consumers. + // Capture follows the flag; replay selects reviewer policy from the saved checkpoint. let guardian_context_mode = GuardianContextMode::from_features(&config.features); - thread_extension_data.insert(crate::context::GuardianReviewEvidence::new( - guardian_context_mode, - )); + thread_extension_data.insert(crate::context::GuardianReviewEvidence::default()); // Kick off independent async setup tasks in parallel to reduce startup latency. // // - initialize thread persistence with new or resumed session info diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 95b83eca64..b6f4356980 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -103,14 +103,14 @@ impl RequestUserInputHandler { )) })?; if turn.config.features.enabled(Feature::GuardianApproval) { + let history = session.conversation_history_snapshot().await; session .services .thread_extension_data .get_or_init(GuardianReviewEvidence::default) - .record_user_input(&call_id, &questions, &response); + .record_user_input(history.as_ref(), &call_id, &questions, &response); } - // Capture and consumption use the same fixed thread feature setting. Legacy - // threads must not construct retained answers, persist them, or advance their revision. + // Capture follows the session flag even while an older checkpoint uses legacy review. if turn.config.features.enabled(Feature::GuardianApproval) && session.guardian_context_mode == GuardianContextMode::ThreadOwned { diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 96bc27c43c..dc368eb8b2 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -117,7 +117,7 @@ async fn request_user_input_sets_non_blocking_outside_plan_mode( session .services .thread_extension_data - .insert(GuardianReviewEvidence::new(session.guardian_context_mode)); + .insert(GuardianReviewEvidence::default()); let original_history = session.conversation_history_snapshot().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); diff --git a/codex-rs/core/tests/suite/guardian_authorization.rs b/codex-rs/core/tests/suite/guardian_authorization.rs index 91e3ade7c7..9ce09499ee 100644 --- a/codex-rs/core/tests/suite/guardian_authorization.rs +++ b/codex-rs/core/tests/suite/guardian_authorization.rs @@ -5,9 +5,11 @@ use anyhow::Result; use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::context::ContextualUserFragment; +use codex_core::context::GuardianContextMode; use codex_core::context::InternalContextSource; use codex_core::context::InternalModelContextFragment; use codex_features::Feature; +use codex_history::RolloutItem; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -42,11 +44,14 @@ enum PendingReviewChange { VerifiedAnswer, } -#[test_case(PendingReviewChange::UserInstruction; "new user instruction")] -#[test_case(PendingReviewChange::VerifiedAnswer; "verified answer")] +#[test_case(PendingReviewChange::UserInstruction, GuardianContextMode::ThreadOwned; "new user instruction")] +#[test_case(PendingReviewChange::VerifiedAnswer, GuardianContextMode::ThreadOwned; "verified answer")] +#[test_case(PendingReviewChange::UserInstruction, GuardianContextMode::Legacy; "migration user instruction")] +#[test_case(PendingReviewChange::VerifiedAnswer, GuardianContextMode::Legacy; "migration verified answer")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_revalidates_owning_session_before_allow( change: PendingReviewChange, + review_mode: GuardianContextMode, ) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -116,7 +121,7 @@ async fn guardian_revalidates_owning_session_before_allow( ]).await; let base_url = format!("{}/v1", streaming_server.uri()); let server = responses::start_mock_server().await; - let test = test_codex() + let mut test = test_codex() .with_model("test-gpt-5.1-codex") .with_config(move |config| { config.model_provider.base_url = Some(base_url); @@ -140,6 +145,26 @@ async fn guardian_revalidates_owning_session_before_allow( .with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?) .build_with_auto_env(&server) .await?; + if review_mode == GuardianContextMode::Legacy { + test.codex.ensure_rollout_materialized().await; + test.codex = super::guardian_checkpoint_migration::resume( + &test, + &test.codex, + vec![RolloutItem::Compacted(serde_json::from_value(json!({ + "message": "old checkpoint", + "replacement_history": [{ + "type": "compaction", "id": "old", "encrypted_content": "unknown producer" + }] + }))?)], + ) + .await?; + } + assert_eq!( + GuardianContextMode::from_history( + test.codex.conversation_history_snapshot().await.as_ref() + ), + review_mode, + ); test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/guardian_checkpoint_migration_tests.rs b/codex-rs/core/tests/suite/guardian_checkpoint_migration_tests.rs new file mode 100644 index 0000000000..a7c23dad61 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_checkpoint_migration_tests.rs @@ -0,0 +1,257 @@ +//! Old encrypted checkpoints keep legacy review while newly captured answers survive migration. + +use std::sync::Arc; + +use anyhow::Result; +use codex_core::CodexThread; +use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; +use codex_core::config::Constrained; +use codex_core::context::GuardianContextMode; +use codex_features::Feature; +use codex_history::InitialHistory; +use codex_history::ResumedHistory; +use codex_history::RolloutItem; +use codex_history::VerifiedAnswer; +use codex_history::VerifiedQuestionAnswer; +use codex_login::CodexAuth; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadHistoryMode; +use codex_protocol::user_input::UserInput; +use codex_thread_store::LoadThreadHistoryParams; +use core_test_support::responses; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use test_case::test_case; + +async fn finish_turn(thread: &CodexThread) { + wait_for_event_match(thread, |event| match event { + EventMsg::TurnComplete(_) => Some(()), + EventMsg::Error(error) => panic!("unexpected turn error: {error:?}"), + _ => None, + }) + .await; +} + +async fn saved_history(test: &TestCodex, thread: &CodexThread) -> Result> { + thread.flush_rollout().await?; + Ok(test + .thread_store + .load_latest_model_context(LoadThreadHistoryParams { + thread_id: thread.session_configured().thread_id, + include_archived: false, + }) + .await? + .items) +} + +pub(super) async fn resume( + test: &TestCodex, + thread: &CodexThread, + history: Vec, +) -> Result> { + let thread_id = thread.session_configured().thread_id; + let environments = thread.environment_selections().await; + thread.shutdown_and_wait().await?; + test.thread_manager.remove_thread(&thread_id).await; + let mut config = test.config.clone(); + config.features.enable(Feature::GuardianThreadContext)?; + Ok(test + .thread_manager + .start_thread(StartThreadOptions { + environments: Some(environments), + initial_history: InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Arc::new(serde_json::from_value(serde_json::to_value(history)?)?), + rollout_path: None, + }), + ..StartThreadOptions::new(config) + }) + .await? + .thread) +} + +#[test_case(ThreadHistoryMode::Legacy; "legacy")] +#[test_case(ThreadHistoryMode::Paginated; "paginated")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn old_checkpoint_migrates_after_compaction_and_resume( + history_mode: ThreadHistoryMode, +) -> Result<()> { + core_test_support::skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let test = test_codex() + .with_history_mode(history_mode) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model("gpt-5.5") + .with_model_info_override("gpt-5.5", |model| { + model.comp_hash = Some("compatible".to_owned()); + model.auto_review_model_override = Some(model.slug.clone()); + }) + .with_config(|config| { + config + .features + .disable(Feature::TokenBudget) + .expect("disable token budget"); + for feature in [ + Feature::GuardianReuseParentCompaction, + Feature::DefaultModeRequestUserInput, + ] { + config.features.enable(feature).expect("enable review flow"); + } + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }) + .build_with_auto_env(&server) + .await?; + // Old wire format, including an ordinary instruction recorded after the checkpoint. + let history: Vec = serde_json::from_value(json!([ + {"type": "compacted", "payload": { + "message": "old checkpoint", + "replacement_history": [{ + "type": "compaction", "id": "old", "encrypted_content": "opaque checkpoint" + }] + }}, + {"type": "response_item", "payload": { + "type": "message", "id": "restriction", "role": "user", + "content": [{"type": "input_text", "text": "Keep the working tree unchanged."}] + }} + ]))?; + test.codex.ensure_rollout_materialized().await; + test.codex.append_rollout_items(&history).await?; + let thread = resume(&test, &test.codex, history).await?; + assert_eq!( + GuardianContextMode::from_history(thread.conversation_history_snapshot().await.as_ref()), + GuardianContextMode::Legacy + ); + + let review = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call( + "ask", + "request_user_input", + &json!({ + "questions": [{ + "id": "publish", "header": "Publish", + "question": "Where may I publish?", + "options": [ + {"label": "Private", "description": "Private repository only."}, + {"label": "Nowhere", "description": "Keep local."} + ] + }] + }) + .to_string(), + ), + responses::ev_completed("question"), + ]), + responses::sse(vec![ + responses::ev_function_call( + "exec", + "exec_command", + r#"{"cmd":"echo migration","sandbox_permissions":"require_escalated"}"#, + ), + responses::ev_completed("action"), + ]), + responses::sse(vec![ + responses::ev_assistant_message( + "decision", + r#"{"risk_level":"low","user_authorization":"high","outcome":"allow"}"#, + ), + responses::ev_completed("review"), + ]), + responses::sse(vec![responses::ev_completed("done")]), + ], + ) + .await; + thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Check authorization, then run the command.".to_owned(), + text_elements: Vec::new(), + }])) + .await?; + let question = wait_for_event_match(&thread, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + EventMsg::Error(error) => panic!("unexpected question error: {error:?}"), + _ => None, + }) + .await; + thread + .submit(Op::UserInputAnswer { + id: question.turn_id.clone(), + response: serde_json::from_value(json!({ + "answers": {"publish": {"answers": ["Private"]}} + }))?, + }) + .await?; + finish_turn(&thread).await; + let requests = review.requests(); + let guardian = requests + .iter() + .find(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian") + .expect("Guardian reviewed the action"); + let prompt = guardian.message_input_texts("user").join("\n"); + assert!(prompt.contains("Private repository only.")); + assert!(prompt.contains("Keep the working tree unchanged.")); + + let before_compaction = saved_history(&test, &thread).await?; + responses::mount_sse_once( + &server, + responses::sse(vec![ + json!({"type": "response.output_item.done", "item": { + "type": "compaction", "id": "migrated", "encrypted_content": "new checkpoint" + }}), + responses::ev_completed("compacted"), + ]), + ) + .await; + thread.submit(Op::Compact).await?; + finish_turn(&thread).await; + assert_eq!( + GuardianContextMode::from_history(thread.conversation_history_snapshot().await.as_ref()), + GuardianContextMode::Legacy + ); + let after_compaction = saved_history(&test, &thread).await?; + let expected_answer = VerifiedAnswer { + turn_id: question.turn_id, + call_id: "ask".to_owned(), + questions: vec![VerifiedQuestionAnswer { + question: "Where may I publish?\nPrivate: Private repository only.".to_owned(), + answer: "Private".to_owned(), + }], + }; + let mut thread = thread; + // Both replayed suffix events and compacted checkpoints must preserve the accepted answer. + for (saved, expected_hash) in [ + (before_compaction, None), + (after_compaction, Some("compatible")), + ] { + thread = resume(&test, &thread, saved).await?; + let history = thread.conversation_history_snapshot().await; + let answers = history + .retained_context() + .expect("retained answer evidence") + .verified_answers() + .collect::>(); + assert_eq!( + ( + GuardianContextMode::from_history(history.as_ref()), + history.latest_compaction_model_hash(), + answers, + ), + ( + GuardianContextMode::ThreadOwned, + expected_hash, + vec![&expected_answer] + ), + ); + } + thread.shutdown_and_wait().await?; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/guardian_subagent_authorization.rs b/codex-rs/core/tests/suite/guardian_subagent_authorization.rs index e11b940801..0980a5f905 100644 --- a/codex-rs/core/tests/suite/guardian_subagent_authorization.rs +++ b/codex-rs/core/tests/suite/guardian_subagent_authorization.rs @@ -73,6 +73,7 @@ enum RootAnswer { enum RootContext { Legacy, Retained, + Migrating, RetainedAtMessageLimit, } @@ -141,6 +142,8 @@ async fn mount_completion( #[test_case(RootAnswer::Oversized, RootContext::Legacy; "legacy_oversized_answer")] #[test_case(RootAnswer::Complete, RootContext::Retained; "retained_complete_answer")] #[test_case(RootAnswer::Oversized, RootContext::Retained; "retained_oversized_answer")] +#[test_case(RootAnswer::Complete, RootContext::Migrating; "migrating_complete_answer")] +#[test_case(RootAnswer::Oversized, RootContext::Migrating; "migrating_oversized_answer")] #[test_case(RootAnswer::Complete, RootContext::RetainedAtMessageLimit; "bounded_retained_root_messages")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_subagent_review_preserves_late_root_user_authorization( @@ -156,7 +159,7 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization( let retained_context_enabled = !matches!(root_context, RootContext::Legacy); let evidence_complete = matches!(root_context, RootContext::Legacy) || matches!(root_answer, RootAnswer::Complete); - let queued_approval = matches!(root_context, RootContext::Retained) + let queued_approval = matches!(root_context, RootContext::Retained | RootContext::Migrating) && matches!(root_answer, RootAnswer::Complete); let server = start_mock_server().await; let mut builder = test_codex().with_config(move |config| { @@ -181,7 +184,30 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization( .set_permission_profile(PermissionProfile::workspace_write()) .expect("set workspace-write permissions"); }); - let test = builder.build_with_auto_env(&server).await?; + let mut test = builder.build_with_auto_env(&server).await?; + if matches!(root_context, RootContext::Migrating) { + let mut checkpoint: CompactedItem = serde_json::from_value(json!({ + "message": "Old checkpoint before the current user instructions.", + "replacement_history": [{ + "type": "compaction", "id": "old", "encrypted_content": "unknown producer" + }] + }))?; + checkpoint.retained_context = Some(Default::default()); + test.codex.ensure_rollout_materialized().await; + test.codex = super::guardian_checkpoint_migration::resume( + &test, + &test.codex, + vec![RolloutItem::Compacted(checkpoint)], + ) + .await?; + assert!( + test.codex + .conversation_history_snapshot() + .await + .retained_context() + .is_none() + ); + } let root_thread_id = test.session_configured.thread_id; let mut created_threads = test.thread_manager.subscribe_thread_created(); @@ -227,7 +253,7 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization( // Exceed both the retained-record storage cap and the reviewer text budget. let oversized_instruction = "Root instruction 0. ".repeat(1_000); let mut root_history_items = Vec::new(); - if !matches!(root_context, RootContext::RetainedAtMessageLimit) { + if matches!(root_context, RootContext::Legacy | RootContext::Retained) { // Older saved histories can contain these unannotated synthetic messages. root_history_items.extend( [ @@ -493,7 +519,7 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization( messages.extend(answer_message); messages } - RootContext::Retained => { + RootContext::Retained | RootContext::Migrating => { let mut messages = vec![GuardianRootMessage::RetainedContextScope]; if !evidence_complete { messages.push(GuardianRootMessage::IncompleteVerifiedAnswers); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 6e08c580dc..59e4019e6e 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -73,6 +73,8 @@ mod external_auth; mod fork_thread; mod git_enrichment; mod guardian_authorization; +#[path = "guardian_checkpoint_migration_tests.rs"] +mod guardian_checkpoint_migration; // Uses the same command-approval harness as guardian_review below. mod canonical_plugin_connectors; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs b/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs index f67f8aac99..85ef325a6d 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs @@ -12,7 +12,6 @@ use super::score::GuardianV2ScoreProgress; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::context::GuardianContextMode; -use codex_core::context::GuardianReviewEvidence; use codex_extension_api::ApprovalDecision; use codex_extension_api::ApprovalDecisionInput; use codex_extension_api::ApprovalReviewContributor; @@ -160,14 +159,12 @@ async fn cached_evidence( record_fast_decision(metrics, "deferred", "scoring_failure"); return Err(GuardianReviewReason::ScoringFailure); } - let context_mode = store - .get_or_init(GuardianReviewEvidence::default) - .context_mode(); + let history = thread.conversation_history_snapshot().await; + let context_mode = GuardianContextMode::from_history(history.as_ref()); if context_mode == GuardianContextMode::ThreadOwned { let sampler = store .get::() .ok_or(GuardianReviewReason::MissingScore)?; - let history = thread.conversation_history_snapshot().await; if select_parent_compaction( context_mode, config, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/budget_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/budget_tests.rs index 9454713ef8..db98c57dd2 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/budget_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/budget_tests.rs @@ -175,6 +175,7 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { .await?; let history: Arc = match evidence { BudgetEvidence::Checkpoint => Arc::new(TestRetainedHistory { + retained_context: None, retained: history.clone(), current: TestConversationHistory(history), compaction_model_hash: Some("budget-checkpoint".to_owned()), diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 38440c4f6c..90b2761204 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -411,9 +411,14 @@ struct TestRetainedHistory { current: TestConversationHistory, retained: Vec, compaction_model_hash: Option, + retained_context: Option, } impl ConversationHistorySnapshot for TestRetainedHistory { + fn retained_context(&self) -> Option<&codex_history::RetainedContext> { + self.retained_context.as_ref() + } + fn latest_compaction_model_hash(&self) -> Option<&str> { self.compaction_model_hash.as_deref() } @@ -2997,6 +3002,7 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result< retained, current: conversation_history, compaction_model_hash: parent_model.comp_hash.clone(), + retained_context: thread_context_enabled.then(codex_history::RetainedContext::default), }; thread_store.insert(parent_model); @@ -3091,6 +3097,8 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result< conversation_history: Arc::new(TestRetainedHistory { current: TestConversationHistory(vec![latest_compaction, oversized_compaction]), retained: Vec::new(), + retained_context: thread_context_enabled + .then(codex_history::RetainedContext::default), compaction_model_hash: thread_store .get::() .and_then(|model| model.comp_hash.clone()), diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/observation.rs b/codex-rs/ext/guardian-v2/src/async_scorer/observation.rs index a0c222af39..3da7b0817c 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/observation.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/observation.rs @@ -214,7 +214,7 @@ impl GuardianV2Extension { let guardian_evidence = input .thread_store .get_or_init(GuardianReviewEvidence::default); - let context_mode = guardian_evidence.context_mode(); + let context_mode = GuardianContextMode::from_history(input.conversation_history.as_ref()); let selected_compaction = match select_parent_compaction( context_mode, &guardian_config,