From 28097e98ebcb5e7eaa2e14534e60337f209a8a80 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 1 Sep 2026 14:18:42 +0000 Subject: [PATCH] Preserve Guardian history across thread reconstruction (#42065) ## Why Guardian review evidence must remain available after a compacted thread is resumed or forked, while rolled-back or parent-local evidence must not grant authorization in a different context. ## What changed - Store the bounded, model-invisible Guardian transcript alongside compacted rollout history and restore it from the newest surviving checkpoint during replay. - Trim Guardian history at rollback boundaries, clearing it when the boundary has already been evicted. - Preserve the checkpoint for user-initiated forks, but remove it when spawning a subagent so parent review evidence is not inherited. - Keep the new rollout field optional for compatibility with existing compacted records and legacy readers. ## Testing - Cover Guardian history across compaction, restart, paginated and pathless stores, user forks, rollback, bounded replay, serialization, and subagent forks. GitOrigin-RevId: 6ab076d5e0c3aac4d6a41ebd50ba2b200c90213e --- .../app-server-exports-stable.json.zst | Bin 145637 -> 145640 bytes .../src/protocol/thread_history.rs | 1 + .../thread_history_projection_tests.rs | 1 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 3 + codex-rs/core/src/agent/control_tests.rs | 14 ++ codex-rs/core/src/context_manager/history.rs | 31 +++- codex-rs/core/src/session/mod.rs | 8 +- .../src/session/rollout_reconstruction.rs | 57 +++--- .../session/rollout_reconstruction_tests.rs | 24 +++ codex-rs/core/src/session/tests.rs | 5 + codex-rs/core/tests/suite/guardian_history.rs | 175 +++++++++++++++++- codex-rs/guardian-context/src/history.rs | 15 +- .../guardian-context/src/history_tests.rs | 13 ++ codex-rs/history/src/guardian_history.rs | 21 +++ codex-rs/history/src/lib.rs | 4 + codex-rs/history/src/rollout_payload.rs | 4 + codex-rs/history/src/tests.rs | 10 + codex-rs/rollout/src/metadata_tests.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../src/local/rollout_migration_tests.rs | 3 + .../thread-store/src/thread_metadata_sync.rs | 1 + .../resume_picker_transcript_preview_tests.rs | 3 + 23 files changed, 369 insertions(+), 27 deletions(-) create mode 100644 codex-rs/history/src/guardian_history.rs 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 c3b3cedeb4396e6d47ac6222ca2621bb1f87c4fb..fe8c718526c8571492a09dac054dbc4eab1ce573 100644 GIT binary patch delta 18159 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%0D#Zsw)~6b-fMd;3jUO$RsiK0kkaLh{nU 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 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 40d3d9fc7e..1da1ee24d3 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -4056,6 +4056,7 @@ mod tests { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, 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 4eb2c02267..cc036e2b95 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 @@ -200,6 +200,7 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() { let compacted = project(RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: 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 485336edd7..1d7b857cff 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()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(1), first_window_id: None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index b0dbd8a162..e5518b2729 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -996,6 +996,9 @@ impl AgentControl { RolloutItem::Compacted(compacted) => { // This checkpoint belongs to the inherited parent prefix. compacted.latest_token_usage_record = None; + // Parent-local review evidence must not become the child's authorization. + // Root user authorization is collected separately by the host. + compacted.guardian_history = 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; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2b2d81eda5..a7acf93169 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1410,6 +1410,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()]), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1525,6 +1526,7 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov } .into(), ]), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -2023,6 +2025,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { replacement_history: Some( replacement_history.into_iter().map(Into::into).collect(), ), + guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![ + user_message("Parent-local approval must not be inherited."), + ])), mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -2079,6 +2084,13 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { .await .expect("child thread should be registered"); let history = child_thread.session.clone_history().await; + assert!( + !history_contains_text( + history.conversation_history_snapshot().review_items(), + "Parent-local approval must not be inherited.", + ), + "a subagent must not inherit its parent review checkpoint", + ); assert!( history_contains_text(history.raw_items(), "compacted parent summary"), "forked child history should retain compacted non-hint content" @@ -2207,6 +2219,7 @@ async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_p replacement_history: Some( replacement_history.into_iter().map(Into::into).collect(), ), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -2362,6 +2375,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, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f81c56a05f..bd7429e62b 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,5 +1,5 @@ //! Model history and bounded original evidence for approval review. -//! Compaction replaces only model history; explicit resets also replace retained evidence. +//! Compaction replaces only model history; replay restores retained evidence and rollback trims it. use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; @@ -20,6 +20,7 @@ use codex_extension_api::ConversationHistorySnapshot; use codex_guardian_context::SectionHistory; use codex_guardian_context::TranscriptHistory; use codex_history::CodexHarnessMetadata; +use codex_history::GuardianHistoryCheckpoint; use codex_history::ResponseItemEnvelope; use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::BaseInstructions; @@ -149,6 +150,28 @@ impl ContextManager { }) } + pub(crate) fn guardian_history_checkpoint(&self) -> Option { + self.review_history + .as_ref() + .map(|history| GuardianHistoryCheckpoint(history.items().cloned().collect())) + } + + pub(crate) fn restore_guardian_history( + &mut self, + checkpoint: Option<&GuardianHistoryCheckpoint>, + ) { + let generation = self + .review_history + .as_ref() + .map_or(self.history_version, TranscriptHistory::generation) + .saturating_add(1); + self.review_history = checkpoint.map(|checkpoint| { + let mut history = TranscriptHistory::new(generation); + history.reset(checkpoint.0.iter()); + history + }); + } + pub(crate) fn token_info(&self) -> Option { self.token_info.clone() } @@ -420,6 +443,11 @@ impl ContextManager { user_positions[user_positions.len() - n_from_end] }; + let mut review_history = self.review_history.take(); + if let Some(history) = &mut review_history { + history.truncate_before(&snapshot[cut_idx].item); + } + cut_idx = self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx); @@ -451,6 +479,7 @@ impl ContextManager { } self.replace_annotated(retained_items); + self.review_history = review_history; } pub(crate) fn update_token_info( diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index aa0bfe12cc..c8b816367f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1536,6 +1536,7 @@ impl Session { ) -> Option { let rollout_reconstruction::RolloutReconstruction { mut history, + guardian_history, previous_turn_settings, reference_context_item, world_state_baseline, @@ -1578,6 +1579,9 @@ impl Session { reference_context_item, HistoryReplacement::Reset, ); + state + .history + .restore_guardian_history(guardian_history.as_ref()); if let Some(world_state) = world_state_baseline { state.history.set_world_state_baseline(world_state); } @@ -3711,9 +3715,10 @@ impl Session { for envelope in &mut items { Self::assign_missing_response_item_id(&mut envelope.item); } - let compacted_item = CompactedItem { + let mut compacted_item = CompactedItem { message: metadata.message, replacement_history: Some(items.clone()), + guardian_history: None, mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(), window_number: Some(metadata.window_number), first_window_id: Some(metadata.window_ids.first_window_id.to_string()), @@ -3737,6 +3742,7 @@ impl Session { reference_context_item.clone(), HistoryReplacement::Compaction, ); + compacted_item.guardian_history = state.history.guardian_history_checkpoint(); 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 46e94eb6e4..786e26c2d1 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) guardian_history: Option, pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, pub(super) world_state_baseline: Option, @@ -43,6 +44,13 @@ enum TurnReferenceContextItem { Latest(Box), } +#[derive(Debug, Clone, Copy)] +// The selected checkpoint and its replay tail must belong to the same surviving segment. +struct ReplayCheckpoint<'a> { + compacted: &'a CompactedItem, + suffix: &'a [RolloutItem], +} + #[derive(Debug, Default)] struct ActiveReplaySegment<'a> { turn_id: Option, @@ -50,7 +58,7 @@ struct ActiveReplaySegment<'a> { previous_turn_settings: Option, reference_context_item: TurnReferenceContextItem, world_state_replay: Vec<&'a RolloutItem>, - base_replacement_history: Option<&'a [ResponseItemEnvelope]>, + base_compaction: Option>, window: Option, } @@ -61,7 +69,7 @@ fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&s fn finalize_active_segment<'a>( active_segment: ActiveReplaySegment<'a>, - base_replacement_history: &mut Option<&'a [ResponseItemEnvelope]>, + base_compaction: &mut Option>, previous_turn_settings: &mut Option, reference_context_item: &mut TurnReferenceContextItem, world_state_replay: &mut Vec<&'a RolloutItem>, @@ -92,10 +100,10 @@ fn finalize_active_segment<'a>( // A surviving replacement-history checkpoint is a complete history base. Once we // know the newest surviving one, older rollout items do not affect rebuilt history. - if base_replacement_history.is_none() - && let Some(segment_base_replacement_history) = active_segment.base_replacement_history + if base_compaction.is_none() + && let Some(segment_base_compaction) = active_segment.base_compaction { - *base_replacement_history = Some(segment_base_replacement_history); + *base_compaction = Some(segment_base_compaction); } if window.is_none() { @@ -147,7 +155,7 @@ impl Session { _ => None, }) }; - let mut base_replacement_history: Option<&[ResponseItemEnvelope]> = None; + let mut base_compaction = None; let mut previous_turn_settings = None; let mut reference_context_item = TurnReferenceContextItem::NeverSet; let mut world_state_replay = Vec::new(); @@ -155,9 +163,6 @@ impl Session { // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes // "skip the next N user-turn segments we finalize". let mut pending_rollback_turns = 0usize; - // Borrowed suffix of rollout items newer than the newest surviving replacement-history - // checkpoint. If no such checkpoint exists, this remains the full rollout. - let mut rollout_suffix = rollout_items; // Reverse replay accumulates rollout items into the newest in-progress turn segment until // we hit its matching `TurnStarted`, at which point the segment can be finalized. let mut active_segment: Option> = None; @@ -189,11 +194,13 @@ impl Session { ) { active_segment.reference_context_item = TurnReferenceContextItem::Cleared; } - if active_segment.base_replacement_history.is_none() - && let Some(replacement_history) = &compacted.replacement_history + if active_segment.base_compaction.is_none() + && compacted.replacement_history.is_some() { - active_segment.base_replacement_history = Some(replacement_history); - rollout_suffix = &rollout_items[index + 1..]; + active_segment.base_compaction = Some(ReplayCheckpoint { + compacted, + suffix: &rollout_items[index + 1..], + }); } } RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { @@ -270,7 +277,7 @@ impl Session { { finalize_active_segment( active_segment, - &mut base_replacement_history, + &mut base_compaction, &mut previous_turn_settings, &mut reference_context_item, &mut world_state_replay, @@ -298,7 +305,7 @@ impl Session { | RolloutItem::InterAgentCommunicationMetadata { .. } => {} } - if base_replacement_history.is_some() + if base_compaction.is_some() && previous_turn_settings.is_some() && !matches!(reference_context_item, TurnReferenceContextItem::NeverSet) { @@ -312,7 +319,7 @@ impl Session { if let Some(active_segment) = active_segment.take() { finalize_active_segment( active_segment, - &mut base_replacement_history, + &mut base_compaction, &mut previous_turn_settings, &mut reference_context_item, &mut world_state_replay, @@ -331,12 +338,16 @@ impl Session { let mut history = ContextManager::new(); let mut saw_legacy_compaction_without_replacement_history = false; - if let Some(base_replacement_history) = base_replacement_history { - history.replace_annotated(base_replacement_history.to_vec()); + if let Some(checkpoint) = base_compaction + && let Some(items) = &checkpoint.compacted.replacement_history + { + history.replace_annotated(items.clone()); + history.restore_guardian_history(checkpoint.compacted.guardian_history.as_ref()); } // 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 // instead of an eagerly loaded `&[RolloutItem]`. + let rollout_suffix = base_compaction.map_or(rollout_items, |checkpoint| checkpoint.suffix); for item in rollout_suffix { match item { RolloutItem::ResponseItem(response_item) => { @@ -354,11 +365,10 @@ impl Session { } RolloutItem::InterAgentCommunicationMetadata { .. } => {} RolloutItem::Compacted(compacted) => { - if let Some(replacement_history) = &compacted.replacement_history { - // This should actually never happen, because the reverse loop above (to build rollout_suffix) - // should stop before any compaction that has Some replacement_history - history.replace_annotated(replacement_history.clone()); - } else { + // Reverse replay already chose the newest surviving checkpoint. Any newer + // replacement checkpoint belongs to a rolled-back turn; replay its original + // items so the rollback can still find the removed user boundary. + if compacted.replacement_history.is_none() { saw_legacy_compaction_without_replacement_history = true; // Legacy rollouts without `replacement_history` should rebuild the // historical TurnContext at the correct insertion point from persisted @@ -441,6 +451,7 @@ impl Session { id: None, }); RolloutReconstruction { + guardian_history: history.guardian_history_checkpoint(), history: history.into_annotated_items(), previous_turn_settings, reference_context_item, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 6d7f2863e3..f6a86d5f08 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()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1123,6 +1124,7 @@ async fn record_initial_history_requires_surviving_full_snapshot_without_user_tu RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1157,6 +1159,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()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1229,6 +1232,7 @@ async fn reconstruct_history_prefers_compacted_window_over_session_meta() { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(2), first_window_id: Some(compacted_first_window_id.to_string()), @@ -1267,6 +1271,7 @@ async fn reconstruct_history_replays_world_state_from_latest_compaction_window() RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(1), first_window_id: None, @@ -1331,6 +1336,9 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() { replacement_history: Some(annotated(vec![assistant_message(&format!( "summary-{window_number}" ))])), + guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![ + user_message("original task"), + ])), mcp_resource_origins: None, window_number: Some(window_number as u64), first_window_id: Some(window_ids[0].to_string()), @@ -1385,8 +1393,16 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() { let bounded = session .reconstruct_history_from_rollout(&turn_context, &bounded_items) .await; + assert_eq!( + bounded.guardian_history.as_ref(), + Some(&codex_history::GuardianHistoryCheckpoint(vec![ + user_message("original task"), + assistant_message("continued working"), + ])), + ); assert_eq!( ( + bounded.guardian_history, bounded.history, bounded.previous_turn_settings, bounded.reference_context_item, @@ -1397,6 +1413,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() { bounded.window_id, ), ( + full.guardian_history, full.history, full.previous_turn_settings, full.reference_context_item, @@ -1429,6 +1446,7 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1459,6 +1477,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1497,6 +1516,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1604,6 +1624,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -1775,6 +1796,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()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -2032,6 +2054,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -2209,6 +2232,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 268de93391..3e631df6b2 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2223,6 +2223,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()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(42), first_window_id: Some(first_window_id.to_string()), @@ -2903,6 +2904,7 @@ fn latest_token_usage_record_stops_at_compaction_checkpoint() { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -4167,6 +4169,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .map(ResponseItemEnvelope::new) .collect(), ), + guardian_history: None, mcp_resource_origins: None, window_number: Some(7), first_window_id: Some(first_window_id.to_string()), @@ -12401,6 +12404,7 @@ async fn sample_rollout( rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: Some(window_number), first_window_id: Some(window_ids.first_window_id.to_string()), @@ -12433,6 +12437,7 @@ async fn sample_rollout( rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: Some(window_number), first_window_id: Some(window_ids.first_window_id.to_string()), diff --git a/codex-rs/core/tests/suite/guardian_history.rs b/codex-rs/core/tests/suite/guardian_history.rs index 6ab40196cb..8bf33956a8 100644 --- a/codex-rs/core/tests/suite/guardian_history.rs +++ b/codex-rs/core/tests/suite/guardian_history.rs @@ -1,21 +1,33 @@ -//! Exercises retained review history through compaction, eviction, large images, and rollback. +//! Exercises retained review history through compaction, resume, fork, eviction, and rollback. use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_core::ForkSnapshot; use codex_core::TurnInputRequest; use codex_core::config::Constrained; +use codex_core::config::ThreadStoreConfig; use codex_features::Feature; +use codex_history::InitialHistory; +use codex_history::ResumedHistory; +use codex_history::RolloutItem; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::AskForApproval; 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::ForkBoundary; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::LoadThreadHistoryParams; +use codex_thread_store::PrepareForkParams; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -33,6 +45,167 @@ use rand::rngs::StdRng; use serde_json::json; use std::collections::HashMap; use std::io::Cursor; +use std::sync::Arc; +use test_case::test_case; + +#[test_case(ThreadHistoryMode::Paginated, ThreadStoreConfig::Local; "paginated")] +// The pathless test store only supports Legacy mode; this case tests the store interface, +// not legacy rollout compatibility. +#[test_case(ThreadHistoryMode::Legacy, ThreadStoreConfig::InMemory { + id: uuid::Uuid::new_v4().to_string(), +}; "pathless")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_history_survives_restart_and_user_fork( + history_mode: ThreadHistoryMode, + store_config: ThreadStoreConfig, +) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + let server = start_mock_server().await; + let pathless_store = match &store_config { + ThreadStoreConfig::Local => None, + ThreadStoreConfig::InMemory { id } => Some(InMemoryThreadStore::for_id(id)), + }; + let mut builder = test_codex() + .with_history_mode(history_mode) + .with_config(move |config| { + config.experimental_thread_store = store_config; + config + .features + .enable(Feature::TokenBudget) + .expect("enable token budget"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }); + let initial = builder.build_with_auto_env(&server).await?; + let authorization = "You may publish the reviewed release."; + mount_sse_once(&server, sse(vec![ev_completed("authorized")])).await; + initial.submit_text_turn(authorization).await?; + initial.codex.submit(Op::Compact).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let restriction = "Keep the release private."; + mount_sse_once(&server, sse(vec![ev_completed("restriction")])).await; + initial.submit_text_turn(restriction).await?; + initial.codex.shutdown_and_wait().await?; + let thread_id = initial.session_configured.thread_id; + initial.thread_manager.remove_thread(&thread_id).await; + let model_context = initial + .thread_store + .load_latest_model_context(LoadThreadHistoryParams { + thread_id, + include_archived: false, + }) + .await?; + // Cross the wire boundary even for the pathless in-memory store: replay must not rely on + // retained runtime metadata or the original live ContextManager. + let items: Vec = + serde_json::from_value(serde_json::to_value(model_context.items)?)?; + let history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Arc::new(items), + rollout_path: None, + }); + let fork = if history_mode == ThreadHistoryMode::Paginated { + let prepared = initial + .thread_store + .prepare_fork(PrepareForkParams { + thread_id, + boundary: ForkBoundary::Latest, + }) + .await?; + initial + .thread_manager + .fork_prepared_thread( + initial.config.clone(), + prepared, + /*thread_source*/ None, + /*parent_trace*/ None, + ClientMcpExtensions::default(), + /*reserved_thread_id*/ None, + ) + .await? + } else { + initial + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + initial.config.clone(), + history.clone(), + /*thread_source*/ None, + /*parent_trace*/ None, + ClientMcpExtensions::default(), + /*reserved_thread_id*/ None, + ) + .await? + }; + let resumed = initial + .thread_manager + .resume_thread_with_history( + initial.config.clone(), + history, + initial.thread_manager.auth_manager(), + /*parent_trace*/ None, + ClientMcpExtensions::default(), + ) + .await?; + for thread in [&fork.thread, &resumed.thread] { + if pathless_store.is_some() { + assert_eq!(thread.rollout_path(), None); + } + let review = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_function_call( + "publish", + "exec_command", + r#"{"cmd":"echo publish","sandbox_permissions":"require_escalated"}"#, + ), + ev_completed("publish"), + ]), + sse(vec![ + ev_assistant_message("review", r#"{"outcome":"deny"}"#), + ev_completed("review"), + ]), + sse(vec![ev_completed("done")]), + ], + ) + .await; + thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Continue with the release.".into(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let requests = review.requests(); + let guardian = requests + .iter() + .find(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian" + }) + .expect("Guardian request"); + let transcript = serde_json::to_string(&guardian.input())?; + assert!(transcript.contains(authorization)); + assert!(transcript.contains(restriction)); + assert!(!serde_json::to_string(&requests[0].input())?.contains(authorization)); + thread.shutdown_and_wait().await?; + } + if let Some(store) = pathless_store { + assert_eq!(store.calls().await.read_thread_by_rollout_path, 0); + if let ThreadStoreConfig::InMemory { id } = &initial.config.experimental_thread_store { + InMemoryThreadStore::remove_id(id); + } + } + Ok(()) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_history_survives_compaction_and_eviction_but_not_rollback() -> Result<()> { diff --git a/codex-rs/guardian-context/src/history.rs b/codex-rs/guardian-context/src/history.rs index 465dceb4d0..b79715e0b3 100644 --- a/codex-rs/guardian-context/src/history.rs +++ b/codex-rs/guardian-context/src/history.rs @@ -1,7 +1,7 @@ //! Bounded, chronological review evidence independent of the parent's compaction. //! //! User messages and other entries have separate limits and keep their original order. -//! Hosts append original items and reset this history on rollback or reconstruction. +//! Hosts append original items, restore bounded checkpoints, and trim rolled-back turns. //! Clones share immutable payloads; eviction changes the generation so readers cannot //! reuse an offset into a different retained prefix. Prompt selection remains caller-owned. @@ -142,6 +142,19 @@ impl TranscriptHistory { } } + /// Removes a rolled-back boundary and everything after it. If retention already + /// evicted the boundary, discard the remaining evidence rather than keep later grants. + pub fn truncate_before(&mut self, boundary: &ResponseItem) { + let index = self.items.iter().position(|(item, _)| { + item.id() + .zip(boundary.id()) + .is_some_and(|(item_id, boundary_id)| item_id == boundary_id) + || item.as_ref() == boundary + }); + self.items.truncate(index.unwrap_or(0)); + self.generation = self.generation.saturating_add(1); + } + /// Generation of the retained prefix, independent of parent compaction. pub fn generation(&self) -> u64 { self.generation diff --git a/codex-rs/guardian-context/src/history_tests.rs b/codex-rs/guardian-context/src/history_tests.rs index b88ac3722d..cd0b9daf98 100644 --- a/codex-rs/guardian-context/src/history_tests.rs +++ b/codex-rs/guardian-context/src/history_tests.rs @@ -18,6 +18,19 @@ fn tool(text: &str) -> ResponseItem { .unwrap() } +#[test] +fn rollback_keeps_the_earlier_prefix_or_clears_an_evicted_boundary() { + let items = [message("keep"), message("roll back"), tool("later")]; + let mut history = TranscriptHistory::default(); + history.reset(items.iter()); + let generation = history.generation(); + history.truncate_before(&items[1]); + assert_eq!(history.items().collect::>(), vec![&items[0]]); + assert!(history.generation() > generation); + history.truncate_before(&items[1]); + assert_eq!(history.items().count(), 0); +} + #[test] fn each_kind_evicts_its_own_oldest_entries_without_reordering() { let users = [message("first"), message("second"), message("third")]; diff --git a/codex-rs/history/src/guardian_history.rs b/codex-rs/history/src/guardian_history.rs new file mode 100644 index 0000000000..1a94fe8b61 --- /dev/null +++ b/codex-rs/history/src/guardian_history.rs @@ -0,0 +1,21 @@ +//! Model-invisible checkpoint of the host's bounded Guardian transcript. + +use codex_protocol::models::ResponseItem; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Original review evidence, separate from the compacted model conversation. +/// Hosts enforce transcript retention limits both when saving and restoring it. +#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct GuardianHistoryCheckpoint(pub Vec); + +impl std::fmt::Debug for GuardianHistoryCheckpoint { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GuardianHistoryCheckpoint") + .field("items", &self.0.len()) + .finish() + } +} diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index a87c83a279..069a243fe4 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -148,12 +148,16 @@ impl JsonSchema for RolloutItem { } } +mod guardian_history; mod rollout_payload; +pub use guardian_history::GuardianHistoryCheckpoint; + #[derive(Clone, Debug, PartialEq)] pub struct CompactedItem { pub message: String, pub replacement_history: Option>, + pub guardian_history: Option, pub mcp_resource_origins: Option, pub window_number: Option, pub first_window_id: Option, diff --git a/codex-rs/history/src/rollout_payload.rs b/codex-rs/history/src/rollout_payload.rs index d68f51c3e1..7939b493e3 100644 --- a/codex-rs/history/src/rollout_payload.rs +++ b/codex-rs/history/src/rollout_payload.rs @@ -150,6 +150,8 @@ pub(super) struct CompactedItemWire<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] replacement_history_metadata: Option>>, #[serde(default, skip_serializing_if = "Option::is_none")] + guardian_history: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] mcp_resource_origins: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] window_number: Option, @@ -191,6 +193,7 @@ impl<'a> From<&'a CompactedItem> for CompactedItemWire<'a> { .collect() }), replacement_history_metadata, + guardian_history: item.guardian_history.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), @@ -257,6 +260,7 @@ impl TryFrom> for CompactedItem { Ok(Self { message: item.message.into_owned(), replacement_history, + guardian_history: item.guardian_history.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 de015b014a..18fca26a7c 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()), ]), + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -299,9 +300,11 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> }; assert_eq!(*legacy_response, response_item); + let checkpoint = crate::GuardianHistoryCheckpoint(vec![response_item.clone()]); let compacted_line = serde_json::to_value(RolloutItem::Compacted(CompactedItem { message: "summary".to_string(), replacement_history: Some(vec![envelope]), + guardian_history: Some(checkpoint.clone()), mcp_resource_origins: Some(McpResourceOriginCheckpoint::default()), window_number: None, first_window_id: None, @@ -311,6 +314,11 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> latest_token_usage_record: None, }))?; + let restored: RolloutItem = serde_json::from_value(compacted_line.clone())?; + let RolloutItem::Compacted(restored) = restored else { + panic!("expected compacted item"); + }; + assert_eq!(restored.guardian_history, Some(checkpoint)); let LegacyRolloutItem::Compacted(legacy) = serde_json::from_value::(compacted_line)? else { @@ -494,6 +502,7 @@ fn compacted_item_serializes_window_number_and_id() -> Result<()> { let item = CompactedItem { message: "summary".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: Some(3), first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()), @@ -531,6 +540,7 @@ fn compacted_item_migrates_legacy_numeric_window_id() -> Result<()> { CompactedItem { message: "summary".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: Some(3), first_window_id: None, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 3d493c3327..867d7565b8 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, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: 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 73f8274118..2891baacf8 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()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(1), first_window_id: None, 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 b1b38ba060..2214bbeed6 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -230,6 +230,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()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(1), first_window_id: None, @@ -1408,6 +1409,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()), + guardian_history: None, mcp_resource_origins: None, window_number: Some(1), first_window_id: None, @@ -1430,6 +1432,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() { } .into(), ]), + guardian_history: None, mcp_resource_origins: None, window_number: Some(2), first_window_id: None, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index dbfac21ef5..2fdadd96dd 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -583,6 +583,7 @@ mod tests { let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: 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 d40ac1565c..4da9949591 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs @@ -73,6 +73,7 @@ fn legacy_transcript_preview_scans_tail_across_compaction() { RolloutItem::Compacted(CompactedItem { message: String::from("summary that is not transcript text"), replacement_history: None, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -206,6 +207,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, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None, @@ -235,6 +237,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, + guardian_history: None, mcp_resource_origins: None, window_number: None, first_window_id: None,