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