mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
Retain user instructions in guardian thread context (#42762)
## Why Guardian review needs the original user instructions alongside verified answers, even after compaction, resume, or rollback. A summarized or legacy checkpoint cannot establish a complete instruction history. ## What changed - Capture text user messages in retained context when `guardian_thread_context` is enabled, preserving their message identity through compaction and replay. - Keep retained instructions and verified answers in arrival order, with independent size and completeness tracking for each family. - Roll back retained evidence at the exact user-message boundary, including steers that share a turn ID, and apply the same semantics during rollout migration. - Treat checkpoints without retained user messages as incomplete while preserving their verified answers. ## Testing - Cover capture, compaction, resume, rollback, child forks, legacy checkpoints, storage bounds, and rollout migration. GitOrigin-RevId: 72706731b54e5ac8645db61b446d2401afb42235
This commit is contained in:
1 parent
cc4b8bdeb8
commit
80d7ca34bc
18 files changed
+1058
-103
No files matched your search
BIN
Binary file not shown.
@@ -2101,10 +2101,9 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
|
|||||||
),
|
),
|
||||||
"a subagent must not inherit its parent review checkpoint",
|
"a subagent must not inherit its parent review checkpoint",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let mut expected_retained_context = codex_history::RetainedContext::default();
|
||||||
history.retained_context(),
|
expected_retained_context.mark_user_messages_incomplete();
|
||||||
&codex_history::RetainedContext::default()
|
assert_eq!(history.retained_context(), &expected_retained_context);
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
history_contains_text(history.raw_items(), "compacted parent summary"),
|
history_contains_text(history.raw_items(), "compacted parent summary"),
|
||||||
"forked child history should retain compacted non-hint content"
|
"forked child history should retain compacted non-hint content"
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ use codex_analytics::CompactionTrigger;
|
|||||||
use codex_analytics::now_unix_seconds;
|
use codex_analytics::now_unix_seconds;
|
||||||
use codex_context_fragments::AnnotatedContent;
|
use codex_context_fragments::AnnotatedContent;
|
||||||
use codex_context_fragments::set_annotated_content;
|
use codex_context_fragments::set_annotated_content;
|
||||||
|
use codex_features::Feature;
|
||||||
use codex_history::CodexHarnessMetadata;
|
use codex_history::CodexHarnessMetadata;
|
||||||
use codex_history::ResponseItemEnvelope;
|
use codex_history::ResponseItemEnvelope;
|
||||||
|
use codex_protocol::ResponseItemId;
|
||||||
use codex_protocol::error::CodexErr;
|
use codex_protocol::error::CodexErr;
|
||||||
use codex_protocol::error::CodexErrorDetails;
|
use codex_protocol::error::CodexErrorDetails;
|
||||||
use codex_protocol::error::Result as CodexResult;
|
use codex_protocol::error::Result as CodexResult;
|
||||||
@@ -355,7 +357,12 @@ async fn run_compact_task_inner_impl(
|
|||||||
let summary_suffix =
|
let summary_suffix =
|
||||||
get_last_assistant_message_from_turn(history_snapshot.raw_items()).unwrap_or_default();
|
get_last_assistant_message_from_turn(history_snapshot.raw_items()).unwrap_or_default();
|
||||||
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
|
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
|
||||||
let user_messages = collect_annotated_user_messages(history_items);
|
let identity = if sess.enabled(Feature::GuardianThreadContext) {
|
||||||
|
CompactedMessageIdentity::Preserve
|
||||||
|
} else {
|
||||||
|
CompactedMessageIdentity::Regenerate
|
||||||
|
};
|
||||||
|
let user_messages = collect_annotated_user_messages(history_items, identity);
|
||||||
|
|
||||||
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
|
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
|
||||||
if let Some(summary_item) = new_history.last_mut() {
|
if let Some(summary_item) = new_history.last_mut() {
|
||||||
@@ -527,6 +534,9 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct CompactedUserMessage {
|
pub(crate) struct CompactedUserMessage {
|
||||||
|
// Keep source identity even when compaction shortens the text, so rollback can
|
||||||
|
// correlate the rebuilt message with thread-owned retained evidence.
|
||||||
|
id: Option<ResponseItemId>,
|
||||||
message: String,
|
message: String,
|
||||||
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
|
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
|
||||||
harness_metadata: Option<CodexHarnessMetadata>,
|
harness_metadata: Option<CodexHarnessMetadata>,
|
||||||
@@ -540,12 +550,24 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<CompactedUser
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) enum CompactedMessageIdentity {
|
||||||
|
Preserve,
|
||||||
|
Regenerate,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn collect_annotated_user_messages(
|
pub(crate) fn collect_annotated_user_messages(
|
||||||
items: &[ResponseItemEnvelope],
|
items: &[ResponseItemEnvelope],
|
||||||
|
identity: CompactedMessageIdentity,
|
||||||
) -> Vec<CompactedUserMessage> {
|
) -> Vec<CompactedUserMessage> {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|envelope| compacted_user_message(&envelope.item, envelope.metadata.clone()))
|
.filter_map(|envelope| compacted_user_message(&envelope.item, envelope.metadata.clone()))
|
||||||
|
.map(|mut message| {
|
||||||
|
if matches!(identity, CompactedMessageIdentity::Regenerate) {
|
||||||
|
message.id = None;
|
||||||
|
}
|
||||||
|
message
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +582,7 @@ fn compacted_user_message(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(CompactedUserMessage {
|
Some(CompactedUserMessage {
|
||||||
|
id: item.id().cloned(),
|
||||||
message: user.message(),
|
message: user.message(),
|
||||||
internal_chat_message_metadata_passthrough: match item {
|
internal_chat_message_metadata_passthrough: match item {
|
||||||
ResponseItem::Message {
|
ResponseItem::Message {
|
||||||
@@ -678,6 +701,7 @@ fn build_compacted_history_with_limit(
|
|||||||
let truncated =
|
let truncated =
|
||||||
truncate_text(&message.message, TruncationPolicy::Tokens(remaining));
|
truncate_text(&message.message, TruncationPolicy::Tokens(remaining));
|
||||||
selected_messages.push(CompactedUserMessage {
|
selected_messages.push(CompactedUserMessage {
|
||||||
|
id: message.id.clone(),
|
||||||
message: truncated,
|
message: truncated,
|
||||||
internal_chat_message_metadata_passthrough: message
|
internal_chat_message_metadata_passthrough: message
|
||||||
.internal_chat_message_metadata_passthrough
|
.internal_chat_message_metadata_passthrough
|
||||||
@@ -692,7 +716,7 @@ fn build_compacted_history_with_limit(
|
|||||||
|
|
||||||
for message in &selected_messages {
|
for message in &selected_messages {
|
||||||
let mut item = ResponseItem::Message {
|
let mut item = ResponseItem::Message {
|
||||||
id: None,
|
id: message.id.clone(),
|
||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
content: vec![ContentItem::InputText {
|
content: vec![ContentItem::InputText {
|
||||||
text: message.message.clone(),
|
text: message.message.clone(),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fn user_message(text: &str) -> ResponseItem {
|
|||||||
|
|
||||||
fn compacted_user_message(text: &str) -> CompactedUserMessage {
|
fn compacted_user_message(text: &str) -> CompactedUserMessage {
|
||||||
CompactedUserMessage {
|
CompactedUserMessage {
|
||||||
|
id: None,
|
||||||
message: text.to_string(),
|
message: text.to_string(),
|
||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
harness_metadata: None,
|
harness_metadata: None,
|
||||||
@@ -129,7 +130,13 @@ fn collect_user_messages_extracts_user_text_only() {
|
|||||||
|
|
||||||
let collected = collect_user_messages(&items);
|
let collected = collect_user_messages(&items);
|
||||||
|
|
||||||
assert_eq!(vec![compacted_user_message("first")], collected);
|
assert_eq!(
|
||||||
|
vec![CompactedUserMessage {
|
||||||
|
id: Some(ResponseItemId::with_suffix("msg", "user")),
|
||||||
|
..compacted_user_message("first")
|
||||||
|
}],
|
||||||
|
collected,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -142,10 +149,11 @@ fn collect_annotated_user_messages_extracts_user_text_only() {
|
|||||||
ResponseItemEnvelope::new(ResponseItem::Other),
|
ResponseItemEnvelope::new(ResponseItem::Other),
|
||||||
];
|
];
|
||||||
|
|
||||||
let collected = collect_annotated_user_messages(&items);
|
let collected = collect_annotated_user_messages(&items, CompactedMessageIdentity::Preserve);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
vec![CompactedUserMessage {
|
vec![CompactedUserMessage {
|
||||||
|
id: None,
|
||||||
message: "first".to_string(),
|
message: "first".to_string(),
|
||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
harness_metadata: Some(CodexHarnessMetadata::default()),
|
harness_metadata: Some(CodexHarnessMetadata::default()),
|
||||||
@@ -223,6 +231,7 @@ fn build_token_limited_compacted_history_truncates_overlong_user_messages() {
|
|||||||
let max_tokens = 16;
|
let max_tokens = 16;
|
||||||
let big = "word ".repeat(200);
|
let big = "word ".repeat(200);
|
||||||
let user_message = CompactedUserMessage {
|
let user_message = CompactedUserMessage {
|
||||||
|
id: Some(ResponseItemId::with_suffix("msg", "long-user")),
|
||||||
message: big.clone(),
|
message: big.clone(),
|
||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
harness_metadata: Some(CodexHarnessMetadata::default()),
|
harness_metadata: Some(CodexHarnessMetadata::default()),
|
||||||
@@ -261,6 +270,7 @@ fn build_token_limited_compacted_history_truncates_overlong_user_messages() {
|
|||||||
other => panic!("unexpected item in history: {other:?}"),
|
other => panic!("unexpected item in history: {other:?}"),
|
||||||
};
|
};
|
||||||
assert_eq!(summary_text, "SUMMARY");
|
assert_eq!(summary_text, "SUMMARY");
|
||||||
|
assert_eq!(history[0].id(), user_message.id.as_ref());
|
||||||
assert_eq!(history[0].metadata, Some(CodexHarnessMetadata::default()));
|
assert_eq!(history[0].metadata, Some(CodexHarnessMetadata::default()));
|
||||||
assert_eq!(history[1].metadata, None);
|
assert_eq!(history[1].metadata, None);
|
||||||
}
|
}
|
||||||
@@ -292,6 +302,7 @@ fn build_compacted_history_preserves_user_message_passthrough_metadata() {
|
|||||||
let history = build_compacted_history(
|
let history = build_compacted_history(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
&[CompactedUserMessage {
|
&[CompactedUserMessage {
|
||||||
|
id: Some(ResponseItemId::with_suffix("msg", "user")),
|
||||||
message: "first user message".to_string(),
|
message: "first user message".to_string(),
|
||||||
internal_chat_message_metadata_passthrough: Some(
|
internal_chat_message_metadata_passthrough: Some(
|
||||||
InternalChatMessageMetadataPassthrough {
|
InternalChatMessageMetadataPassthrough {
|
||||||
@@ -314,7 +325,7 @@ fn build_compacted_history_preserves_user_message_passthrough_metadata() {
|
|||||||
vec![
|
vec![
|
||||||
ResponseItemEnvelope {
|
ResponseItemEnvelope {
|
||||||
item: ResponseItem::Message {
|
item: ResponseItem::Message {
|
||||||
id: None,
|
id: Some(ResponseItemId::with_suffix("msg", "user")),
|
||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
content: vec![ContentItem::InputText {
|
content: vec![ContentItem::InputText {
|
||||||
text: "first user message".to_string(),
|
text: "first user message".to_string(),
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ pub(crate) struct ContextManager {
|
|||||||
review_history: Option<TranscriptHistory>,
|
review_history: Option<TranscriptHistory>,
|
||||||
/// Host facts independent of the model window; snapshots share immutable state.
|
/// Host facts independent of the model window; snapshots share immutable state.
|
||||||
retained_context: Arc<RetainedContext>,
|
retained_context: Arc<RetainedContext>,
|
||||||
|
/// Live and replay instruction capture are enabled together by the session feature flag.
|
||||||
|
retain_user_messages: bool,
|
||||||
/// Bumped whenever history is rewritten, such as compaction or rollback.
|
/// Bumped whenever history is rewritten, such as compaction or rollback.
|
||||||
history_version: u64,
|
history_version: u64,
|
||||||
/// Monotonic user-input/reset revision, independent of compaction's history generation.
|
/// Monotonic user-input/reset revision, independent of compaction's history generation.
|
||||||
@@ -156,6 +158,7 @@ impl ContextManager {
|
|||||||
items: Arc::new(Vec::new()),
|
items: Arc::new(Vec::new()),
|
||||||
review_history: None,
|
review_history: None,
|
||||||
retained_context: Arc::default(),
|
retained_context: Arc::default(),
|
||||||
|
retain_user_messages: false,
|
||||||
history_version: 0,
|
history_version: 0,
|
||||||
user_message_revision: 0,
|
user_message_revision: 0,
|
||||||
token_info: TokenUsageInfo::new_or_append(
|
token_info: TokenUsageInfo::new_or_append(
|
||||||
@@ -180,6 +183,10 @@ impl ContextManager {
|
|||||||
&self.retained_context
|
&self.retained_context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn enable_user_message_retention(&mut self) {
|
||||||
|
self.retain_user_messages = true;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn record_retained_context(&mut self, event: &RetainedContextEvent) -> bool {
|
pub(crate) fn record_retained_context(&mut self, event: &RetainedContextEvent) -> bool {
|
||||||
if !Arc::make_mut(&mut self.retained_context).record(event) {
|
if !Arc::make_mut(&mut self.retained_context).record(event) {
|
||||||
return false;
|
return false;
|
||||||
@@ -188,7 +195,7 @@ impl ContextManager {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn restore_retained_context(&mut self, checkpoint: &RetainedContext) {
|
pub(crate) fn restore_retained_context(&mut self, checkpoint: Option<&RetainedContext>) {
|
||||||
Arc::make_mut(&mut self.retained_context).restore(checkpoint);
|
Arc::make_mut(&mut self.retained_context).restore(checkpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +325,44 @@ impl ContextManager {
|
|||||||
}
|
}
|
||||||
Arc::make_mut(&mut self.items).push(processed);
|
Arc::make_mut(&mut self.items).push(processed);
|
||||||
if crate::context::is_user_authorization_message(item) {
|
if crate::context::is_user_authorization_message(item) {
|
||||||
|
if self.retain_user_messages
|
||||||
|
&& let ResponseItem::Message {
|
||||||
|
content,
|
||||||
|
internal_chat_message_metadata_passthrough,
|
||||||
|
..
|
||||||
|
} = item
|
||||||
|
{
|
||||||
|
let mut complete = internal_chat_message_metadata_passthrough
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| metadata.content_item_kinds.as_ref())
|
||||||
|
.is_some_and(|kinds| {
|
||||||
|
kinds.len() == content.len()
|
||||||
|
&& kinds.iter().all(|kind| kind.0.starts_with("user."))
|
||||||
|
});
|
||||||
|
let text = content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|content| match content {
|
||||||
|
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||||
|
Some(text.as_str())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
complete = false;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
Arc::make_mut(&mut self.retained_context).record_user_message(
|
||||||
|
codex_history::RetainedUserMessage {
|
||||||
|
turn_id: item.turn_id().unwrap_or_default().to_owned(),
|
||||||
|
message_id: item.id().map(|id| id.as_str().to_owned()),
|
||||||
|
text,
|
||||||
|
complete,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete();
|
||||||
|
}
|
||||||
self.user_message_revision = self.user_message_revision.saturating_add(1);
|
self.user_message_revision = self.user_message_revision.saturating_add(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,6 +533,9 @@ impl ContextManager {
|
|||||||
user_positions[user_positions.len() - n_from_end]
|
user_positions[user_positions.len() - n_from_end]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let first_removed_message_id = snapshot[cut_idx]
|
||||||
|
.id()
|
||||||
|
.map(codex_protocol::ResponseItemId::as_str);
|
||||||
let mut review_history = self.review_history.take();
|
let mut review_history = self.review_history.take();
|
||||||
if let Some(history) = &mut review_history {
|
if let Some(history) = &mut review_history {
|
||||||
history.truncate_before(&snapshot[cut_idx].item);
|
history.truncate_before(&snapshot[cut_idx].item);
|
||||||
@@ -528,18 +576,21 @@ impl ContextManager {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.turn_id())
|
.filter_map(|item| item.turn_id())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Arc::make_mut(&mut retained_context).retain_answers(|answer| {
|
if self.retain_user_messages {
|
||||||
// A steer creates an instruction boundary, not a new turn ID. Replay exposes
|
Arc::make_mut(&mut retained_context).rollback(&removed_turns, first_removed_message_id);
|
||||||
// the original calls from rolled-back checkpoints, so prefer the exact source.
|
} else {
|
||||||
if let Some(source_index) = snapshot.iter().rposition(|item| {
|
Arc::make_mut(&mut retained_context).retain_answers(|answer| {
|
||||||
item.turn_id() == Some(answer.turn_id.as_str())
|
// Legacy answers follow their original call, not later steers in the same turn.
|
||||||
&& matches!(&item.item, ResponseItem::FunctionCall { call_id, .. }
|
if let Some(source_index) = snapshot.iter().rposition(|item| {
|
||||||
if call_id == &answer.call_id)
|
item.turn_id() == Some(answer.turn_id.as_str())
|
||||||
}) {
|
&& matches!(&item.item, ResponseItem::FunctionCall { call_id, .. }
|
||||||
return source_index < cut_idx;
|
if call_id == &answer.call_id)
|
||||||
}
|
}) {
|
||||||
!removed_turns.contains(&answer.turn_id.as_str())
|
return source_index < cut_idx;
|
||||||
});
|
}
|
||||||
|
!removed_turns.contains(&answer.turn_id.as_str())
|
||||||
|
});
|
||||||
|
}
|
||||||
self.replace_annotated(retained_items);
|
self.replace_annotated(retained_items);
|
||||||
self.retained_context = retained_context;
|
self.retained_context = retained_context;
|
||||||
self.review_history = review_history;
|
self.review_history = review_history;
|
||||||
|
|||||||
@@ -1265,6 +1265,55 @@ fn drop_last_n_user_turns_preserves_prefix() {
|
|||||||
let retained = history.retained_context().clone();
|
let retained = history.retained_context().clone();
|
||||||
history.drop_last_n_user_turns(/*num_turns*/ 1);
|
history.drop_last_n_user_turns(/*num_turns*/ 1);
|
||||||
assert_eq!(history.retained_context(), &retained);
|
assert_eq!(history.retained_context(), &retained);
|
||||||
|
|
||||||
|
// A steered message shares its source turn, but rollback must keep the earlier
|
||||||
|
// instruction and answer as complete evidence, including after the next compaction.
|
||||||
|
let mut history = ContextManager::default();
|
||||||
|
history.enable_user_message_retention();
|
||||||
|
let mut expected = None;
|
||||||
|
for (id, text) in [
|
||||||
|
("restriction", "Never publish publicly."),
|
||||||
|
("steer", "Check the tests too."),
|
||||||
|
] {
|
||||||
|
let message = ResponseItem::Message {
|
||||||
|
id: Some(ResponseItemId::with_suffix("msg", id)),
|
||||||
|
role: "user".to_owned(),
|
||||||
|
content: vec![ContentItem::InputText {
|
||||||
|
text: text.to_owned(),
|
||||||
|
}],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: Some(
|
||||||
|
InternalChatMessageMetadataPassthrough {
|
||||||
|
turn_id: Some("shared-turn".to_owned()),
|
||||||
|
content_item_kinds: Some(vec![ContentItemKind("user.text".to_owned())]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
history.record_items([&message], TruncationPolicy::Tokens(10_000));
|
||||||
|
if id == "restriction" {
|
||||||
|
history.record_retained_context(&codex_history::RetainedContextEvent::VerifiedAnswer(
|
||||||
|
codex_history::VerifiedAnswer {
|
||||||
|
turn_id: "shared-turn".to_owned(),
|
||||||
|
call_id: "ask".to_owned(),
|
||||||
|
questions: vec![codex_history::VerifiedQuestionAnswer {
|
||||||
|
question: "Publish?".to_owned(),
|
||||||
|
answer: "Only privately.".to_owned(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
));
|
||||||
|
expected = Some(history.retained_context().clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
history.drop_last_n_user_turns(/*num_turns*/ 1);
|
||||||
|
history.replace_compacted(Vec::new());
|
||||||
|
let retained = history.retained_context();
|
||||||
|
assert!(retained.user_messages_complete());
|
||||||
|
assert!(retained.verified_answers_complete());
|
||||||
|
let mut expected = serde_json::to_value(expected.expect("pre-steer evidence")).unwrap();
|
||||||
|
// Rollback removes evidence, but does not reuse its arrival-order sequence numbers.
|
||||||
|
expected["next_order"] = serde_json::json!(3);
|
||||||
|
assert_eq!(serde_json::to_value(retained).unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1590,7 +1590,9 @@ impl Session {
|
|||||||
state
|
state
|
||||||
.history
|
.history
|
||||||
.restore_guardian_history(guardian_history.as_ref());
|
.restore_guardian_history(guardian_history.as_ref());
|
||||||
state.history.restore_retained_context(&retained_context);
|
state
|
||||||
|
.history
|
||||||
|
.restore_retained_context(Some(&retained_context));
|
||||||
if let Some(world_state) = world_state_baseline {
|
if let Some(world_state) = world_state_baseline {
|
||||||
state.history.set_world_state_baseline(world_state);
|
state.history.set_world_state_baseline(world_state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,15 +339,16 @@ impl Session {
|
|||||||
.unwrap_or(u64::MAX);
|
.unwrap_or(u64::MAX);
|
||||||
|
|
||||||
let mut history = ContextManager::new();
|
let mut history = ContextManager::new();
|
||||||
|
if self.enabled(Feature::GuardianThreadContext) {
|
||||||
|
history.enable_user_message_retention();
|
||||||
|
}
|
||||||
let mut saw_legacy_compaction_without_replacement_history = false;
|
let mut saw_legacy_compaction_without_replacement_history = false;
|
||||||
if let Some(checkpoint) = base_compaction
|
if let Some(checkpoint) = base_compaction
|
||||||
&& let Some(items) = &checkpoint.compacted.replacement_history
|
&& let Some(items) = &checkpoint.compacted.replacement_history
|
||||||
{
|
{
|
||||||
history.replace_annotated(items.clone());
|
history.replace_annotated(items.clone());
|
||||||
history.restore_guardian_history(checkpoint.compacted.guardian_history.as_ref());
|
history.restore_guardian_history(checkpoint.compacted.guardian_history.as_ref());
|
||||||
if let Some(retained) = &checkpoint.compacted.retained_context {
|
history.restore_retained_context(checkpoint.compacted.retained_context.as_ref());
|
||||||
history.restore_retained_context(retained);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Materialize exact history semantics from the replay-derived suffix. The eventual lazy
|
// 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
|
// design should keep this same replay shape, but drive it from a resumable reverse source
|
||||||
@@ -386,8 +387,15 @@ impl Session {
|
|||||||
// prompt shape.
|
// prompt shape.
|
||||||
// TODO(ccunningham): if we drop support for None replacement_history compaction items,
|
// TODO(ccunningham): if we drop support for None replacement_history compaction items,
|
||||||
// we can get rid of this second loop entirely and just build `history` directly in the first loop.
|
// we can get rid of this second loop entirely and just build `history` directly in the first loop.
|
||||||
let user_messages =
|
let identity = if self.enabled(Feature::GuardianThreadContext) {
|
||||||
compact::collect_annotated_user_messages(history.annotated_items());
|
compact::CompactedMessageIdentity::Preserve
|
||||||
|
} else {
|
||||||
|
compact::CompactedMessageIdentity::Regenerate
|
||||||
|
};
|
||||||
|
let user_messages = compact::collect_annotated_user_messages(
|
||||||
|
history.annotated_items(),
|
||||||
|
identity,
|
||||||
|
);
|
||||||
let rebuilt = compact::build_compacted_history(
|
let rebuilt = compact::build_compacted_history(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
&user_messages,
|
&user_messages,
|
||||||
@@ -395,7 +403,7 @@ impl Session {
|
|||||||
);
|
);
|
||||||
let retained_context = history.retained_context().clone();
|
let retained_context = history.retained_context().clone();
|
||||||
history.replace_annotated(rebuilt);
|
history.replace_annotated(rebuilt);
|
||||||
history.restore_retained_context(&retained_context);
|
history.restore_retained_context(Some(&retained_context));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
|
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
|
||||||
|
|||||||
@@ -1488,6 +1488,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
|
|||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
let mut retained = codex_history::RetainedContext::default();
|
let mut retained = codex_history::RetainedContext::default();
|
||||||
|
retained.mark_user_messages_incomplete();
|
||||||
retained.record(&answer);
|
retained.record(&answer);
|
||||||
let rollout_items = vec![
|
let rollout_items = vec![
|
||||||
RolloutItem::ResponseItem(user_message("before compact").into()),
|
RolloutItem::ResponseItem(user_message("before compact").into()),
|
||||||
|
|||||||
@@ -1266,6 +1266,9 @@ impl Session {
|
|||||||
session_configuration.clone(),
|
session_configuration.clone(),
|
||||||
initial_auto_compact_window_ids,
|
initial_auto_compact_window_ids,
|
||||||
);
|
);
|
||||||
|
if config.features.enabled(Feature::GuardianThreadContext) {
|
||||||
|
state.history.enable_user_message_retention();
|
||||||
|
}
|
||||||
state.base_instructions_provenance = base_instructions_provenance.clone();
|
state.base_instructions_provenance = base_instructions_provenance.clone();
|
||||||
let managed_network_requirements_configured = config
|
let managed_network_requirements_configured = config
|
||||||
.config_layer_stack
|
.config_layer_stack
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Retained-answer lifecycle through real sessions and the durable thread-store boundary.
|
//! Retained-instruction and answer lifecycles through real sessions and durable checkpoints.
|
||||||
//! Typed fixtures enter via the public rollout append API to isolate storage from producer
|
//! Real user input covers steering, compaction and rollback; legacy replay fixtures also use
|
||||||
//! wiring; compaction, resume, rollback and child forks use production paths.
|
//! the public rollout append API. Resume and child forks use production paths.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -13,11 +13,15 @@ use codex_core::config::ThreadStoreConfig;
|
|||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_history::InitialHistory;
|
use codex_history::InitialHistory;
|
||||||
use codex_history::ResumedHistory;
|
use codex_history::ResumedHistory;
|
||||||
|
use codex_history::RetainedContext;
|
||||||
use codex_history::RetainedContextEvent;
|
use codex_history::RetainedContextEvent;
|
||||||
use codex_history::RolloutItem;
|
use codex_history::RolloutItem;
|
||||||
use codex_history::VerifiedAnswer;
|
use codex_history::VerifiedAnswer;
|
||||||
use codex_history::VerifiedQuestionAnswer;
|
use codex_history::VerifiedQuestionAnswer;
|
||||||
|
use codex_protocol::ResponseItemId;
|
||||||
use codex_protocol::mcp::ClientMcpExtensions;
|
use codex_protocol::mcp::ClientMcpExtensions;
|
||||||
|
use codex_protocol::models::ContentItem;
|
||||||
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::Op;
|
use codex_protocol::protocol::Op;
|
||||||
use codex_protocol::protocol::ThreadHistoryMode;
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
@@ -25,6 +29,7 @@ use codex_protocol::request_user_input::RequestUserInputAnswer;
|
|||||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
use codex_thread_store::LoadThreadHistoryParams;
|
use codex_thread_store::LoadThreadHistoryParams;
|
||||||
|
use core_test_support::responses::ev_assistant_message;
|
||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
use core_test_support::responses::ev_function_call;
|
use core_test_support::responses::ev_function_call;
|
||||||
use core_test_support::responses::ev_function_call_with_namespace;
|
use core_test_support::responses::ev_function_call_with_namespace;
|
||||||
@@ -149,7 +154,7 @@ async fn compact_and_assert_answers(
|
|||||||
test: &TestCodex,
|
test: &TestCodex,
|
||||||
thread: &CodexThread,
|
thread: &CodexThread,
|
||||||
expected: &[VerifiedAnswer],
|
expected: &[VerifiedAnswer],
|
||||||
) -> Result<()> {
|
) -> Result<RetainedContext> {
|
||||||
// Inspect live state by persisting a real compaction checkpoint, not a private getter.
|
// Inspect live state by persisting a real compaction checkpoint, not a private getter.
|
||||||
// Repeating this after resume/rollback also catches checkpoint resurrection.
|
// Repeating this after resume/rollback also catches checkpoint resurrection.
|
||||||
thread.submit(Op::Compact).await?;
|
thread.submit(Op::Compact).await?;
|
||||||
@@ -172,6 +177,355 @@ async fn compact_and_assert_answers(
|
|||||||
actual.verified_answers().cloned().collect::<Vec<_>>(),
|
actual.verified_answers().cloned().collect::<Vec<_>>(),
|
||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
|
Ok(actual.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case(ThreadHistoryMode::Legacy, true; "enabled legacy rollback")]
|
||||||
|
#[test_case(ThreadHistoryMode::Paginated, true; "enabled paginated resume")]
|
||||||
|
#[test_case(ThreadHistoryMode::Legacy, false; "disabled legacy rollback")]
|
||||||
|
#[test_case(ThreadHistoryMode::Paginated, false; "disabled paginated resume")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retained_instructions_keep_identity_across_compaction_and_resume(
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
|
thread_context_enabled: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
const INITIAL: &str = "Never publish publicly.";
|
||||||
|
const STEER: &str = "Also inspect the README.";
|
||||||
|
// Paginated history supports checkpoint resume, but not the full-history read
|
||||||
|
// used by ThreadRollback. Exercise rollback on its supported legacy path.
|
||||||
|
let rollback_counts: &[usize] = match history_mode {
|
||||||
|
ThreadHistoryMode::Legacy => &[1, 0],
|
||||||
|
ThreadHistoryMode::Paginated => &[],
|
||||||
|
};
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let test = test_codex()
|
||||||
|
.with_history_mode(history_mode)
|
||||||
|
.with_config(move |config| {
|
||||||
|
config.experimental_thread_store = ThreadStoreConfig::Local;
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
|
||||||
|
.expect("test context mode");
|
||||||
|
// Exercise local compaction's rebuilt user messages, not an opaque checkpoint.
|
||||||
|
config.model_provider.name = "Local compaction test provider".to_owned();
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.disable(Feature::TokenBudget)
|
||||||
|
.expect("use local compaction");
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(Feature::DefaultModeRequestUserInput)
|
||||||
|
.expect("enable request_user_input");
|
||||||
|
})
|
||||||
|
.build_with_auto_env(&server)
|
||||||
|
.await?;
|
||||||
|
let questions = [
|
||||||
|
("before-steer", "Publish?", "Only privately."),
|
||||||
|
("after-steer", "Publish the README?", "Do not publish it."),
|
||||||
|
];
|
||||||
|
let mut responses = questions
|
||||||
|
.iter()
|
||||||
|
.map(|(call_id, question, _)| {
|
||||||
|
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(call_id),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
responses.push(sse(vec![ev_completed("done")]));
|
||||||
|
responses.extend((0..=rollback_counts.len()).map(|index| {
|
||||||
|
sse(vec![
|
||||||
|
ev_assistant_message(&format!("summary-{index}"), "Compacted inspection context."),
|
||||||
|
ev_completed(&format!("compact-{index}")),
|
||||||
|
])
|
||||||
|
}));
|
||||||
|
let response_mock = mount_sse_sequence(&server, responses).await;
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: INITIAL.to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
let mut answers = Vec::new();
|
||||||
|
for (call_id, question, answer) in questions {
|
||||||
|
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||||
|
EventMsg::RequestUserInput(request) => Some(request.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if answers.is_empty() {
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: STEER.to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
test.codex
|
||||||
|
.submit(Op::UserInputAnswer {
|
||||||
|
id: request.turn_id.clone(),
|
||||||
|
response: RequestUserInputResponse {
|
||||||
|
answers: HashMap::from([(
|
||||||
|
"publish".to_owned(),
|
||||||
|
RequestUserInputAnswer {
|
||||||
|
answers: vec![answer.to_owned()],
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
answers.push(VerifiedAnswer {
|
||||||
|
turn_id: request.turn_id,
|
||||||
|
call_id: call_id.to_owned(),
|
||||||
|
questions: vec![VerifiedQuestionAnswer {
|
||||||
|
question: question.to_owned(),
|
||||||
|
answer: answer.to_owned(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(answers[0].turn_id, answers[1].turn_id);
|
||||||
|
let requests = response_mock.requests();
|
||||||
|
assert_eq!(requests.len(), 3);
|
||||||
|
assert!(requests[0].has_message_with_input_texts("user", |texts| texts == [INITIAL]));
|
||||||
|
for (index, request) in requests.iter().skip(1).enumerate() {
|
||||||
|
assert!(request.has_message_with_input_texts("user", |texts| texts == [STEER]));
|
||||||
|
for answer in &answers[..=index] {
|
||||||
|
let (output, _) = request
|
||||||
|
.function_call_output_content_and_success(&answer.call_id)
|
||||||
|
.context("answer tool output")?;
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&output.context("answer text")?)?;
|
||||||
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
json!({"answers": {"publish": {"answers": [answer.questions[0].answer]}}})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let history = test.codex.conversation_history_snapshot().await;
|
||||||
|
let user_messages = [INITIAL, STEER]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, text)| {
|
||||||
|
let message_id = history
|
||||||
|
.items()
|
||||||
|
.find(|item| {
|
||||||
|
matches!(
|
||||||
|
item,
|
||||||
|
ResponseItem::Message { role, content, .. }
|
||||||
|
if role == "user" && matches!(
|
||||||
|
content.as_slice(),
|
||||||
|
[ContentItem::InputText { text: body }] if body == text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.and_then(ResponseItem::id)
|
||||||
|
.expect("original user-message identity");
|
||||||
|
json!({
|
||||||
|
"order": index * 2, "turn_id": answers[index].turn_id,
|
||||||
|
"message_id": message_id.as_str(), "text": text, "complete": true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let ordered_answers = answers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, answer)| {
|
||||||
|
let mut value = json!(answer);
|
||||||
|
value["order"] = json!(index * 2 + 1);
|
||||||
|
value
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut expected = json!({
|
||||||
|
"user_messages": user_messages, "user_messages_incomplete": false,
|
||||||
|
"verified_answers": ordered_answers, "incomplete": false, "next_order": 4,
|
||||||
|
});
|
||||||
|
if !thread_context_enabled {
|
||||||
|
expected = json!({
|
||||||
|
"user_messages": [], "user_messages_incomplete": true,
|
||||||
|
"verified_answers": [], "incomplete": false, "next_order": 0,
|
||||||
|
});
|
||||||
|
answers.clear();
|
||||||
|
assert!(
|
||||||
|
load_context(&test, &test.codex)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.all(|item| !matches!(item, RolloutItem::RetainedContext(_)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(serde_json::to_value(history.retained_context())?, expected);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(compact_and_assert_answers(&test, &test.codex, &answers).await?)?,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let compacted = test.codex.conversation_history_snapshot().await;
|
||||||
|
for message in &user_messages {
|
||||||
|
assert_eq!(
|
||||||
|
compacted
|
||||||
|
.items()
|
||||||
|
.any(|item| item.id().map(ResponseItemId::as_str) == message["message_id"].as_str()),
|
||||||
|
thread_context_enabled,
|
||||||
|
"only thread-owned context preserves original user-message identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut thread = Arc::clone(&test.codex);
|
||||||
|
for &remaining in rollback_counts {
|
||||||
|
let remaining = if thread_context_enabled { remaining } else { 0 };
|
||||||
|
thread = resume(&test, &thread).await?;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
thread
|
||||||
|
.conversation_history_snapshot()
|
||||||
|
.await
|
||||||
|
.retained_context()
|
||||||
|
)?,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
|
||||||
|
wait_for_event(&thread, |event| {
|
||||||
|
matches!(event, EventMsg::ThreadRolledBack(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
expected["user_messages"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("expected retained user messages")
|
||||||
|
.truncate(remaining);
|
||||||
|
expected["verified_answers"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("expected retained verified answers")
|
||||||
|
.truncate(remaining);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
compact_and_assert_answers(&test, &thread, &answers[..remaining]).await?
|
||||||
|
)?,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
thread = resume(&test, &thread).await?;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
thread
|
||||||
|
.conversation_history_snapshot()
|
||||||
|
.await
|
||||||
|
.retained_context()
|
||||||
|
)?,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
thread.shutdown_and_wait().await?;
|
||||||
|
let requests = response_mock.requests();
|
||||||
|
assert_eq!(requests.len(), 4 + rollback_counts.len());
|
||||||
|
assert!(requests[3].has_message_with_input_texts("user", |texts| texts == [STEER]));
|
||||||
|
if history_mode == ThreadHistoryMode::Legacy {
|
||||||
|
assert!(requests[4].has_message_with_input_texts("user", |texts| texts == [INITIAL]));
|
||||||
|
assert!(!requests[4].has_message_with_input_texts("user", |texts| texts == [STEER]));
|
||||||
|
assert!(!requests[5].has_message_with_input_texts("user", |texts| texts == [INITIAL]));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn disabled_capture_stays_incomplete_after_compaction_and_enabled_resume() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let test = test_codex()
|
||||||
|
.with_config(|config| {
|
||||||
|
config.experimental_thread_store = ThreadStoreConfig::Local;
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.disable(Feature::GuardianThreadContext)
|
||||||
|
.expect("disable instruction capture");
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.disable(Feature::TokenBudget)
|
||||||
|
.expect("use local compaction");
|
||||||
|
config.model_provider.name = "Local compaction test provider".to_owned();
|
||||||
|
})
|
||||||
|
.build_with_auto_env(&server)
|
||||||
|
.await?;
|
||||||
|
mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![ev_completed("initial-turn")]),
|
||||||
|
sse(vec![
|
||||||
|
ev_assistant_message("summary", "Compacted context."),
|
||||||
|
ev_completed("compact"),
|
||||||
|
]),
|
||||||
|
sse(vec![
|
||||||
|
ev_assistant_message("summary-again", "Compacted context."),
|
||||||
|
ev_completed("compact-again"),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "Never publish publicly.".to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let checkpoint = compact_and_assert_answers(&test, &test.codex, &[]).await?;
|
||||||
|
assert!(!checkpoint.user_messages_complete());
|
||||||
|
assert_eq!(checkpoint.ordered_entries().count(), 0);
|
||||||
|
|
||||||
|
let thread_id = test.codex.session_configured().thread_id;
|
||||||
|
test.codex.shutdown_and_wait().await?;
|
||||||
|
test.thread_manager.remove_thread(&thread_id).await;
|
||||||
|
let items: Vec<RolloutItem> = serde_json::from_value(serde_json::to_value(
|
||||||
|
load_context(&test, &test.codex).await?,
|
||||||
|
)?)?;
|
||||||
|
let mut config = test.config.clone();
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(Feature::GuardianThreadContext)
|
||||||
|
.expect("enable instruction capture on resume");
|
||||||
|
let resumed = test
|
||||||
|
.thread_manager
|
||||||
|
.resume_thread_with_history(
|
||||||
|
config,
|
||||||
|
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;
|
||||||
|
assert!(
|
||||||
|
!resumed
|
||||||
|
.conversation_history_snapshot()
|
||||||
|
.await
|
||||||
|
.retained_context()
|
||||||
|
.expect("resumed retained context")
|
||||||
|
.user_messages_complete()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!compact_and_assert_answers(&test, &resumed, &[])
|
||||||
|
.await?
|
||||||
|
.user_messages_complete()
|
||||||
|
);
|
||||||
|
resumed.shutdown_and_wait().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +537,11 @@ async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()>
|
|||||||
.with_history_mode(ThreadHistoryMode::Legacy)
|
.with_history_mode(ThreadHistoryMode::Legacy)
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.experimental_thread_store = ThreadStoreConfig::Local;
|
config.experimental_thread_store = ThreadStoreConfig::Local;
|
||||||
for feature in [Feature::TokenBudget, Feature::DefaultModeRequestUserInput] {
|
for feature in [
|
||||||
|
Feature::TokenBudget,
|
||||||
|
Feature::DefaultModeRequestUserInput,
|
||||||
|
Feature::GuardianThreadContext,
|
||||||
|
] {
|
||||||
config
|
config
|
||||||
.features
|
.features
|
||||||
.enable(feature)
|
.enable(feature)
|
||||||
@@ -341,6 +699,7 @@ async fn retained_answers_cross_real_session_boundaries(
|
|||||||
Feature::DefaultModeRequestUserInput,
|
Feature::DefaultModeRequestUserInput,
|
||||||
Feature::Collab,
|
Feature::Collab,
|
||||||
Feature::MultiAgentV2,
|
Feature::MultiAgentV2,
|
||||||
|
Feature::GuardianThreadContext,
|
||||||
] {
|
] {
|
||||||
config
|
config
|
||||||
.features
|
.features
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub struct RenderedVerifiedAnswers {
|
|||||||
|
|
||||||
/// Renders each response with its original source roles and the existing approximate per-answer budget.
|
/// Renders each response with its original source roles and the existing approximate per-answer budget.
|
||||||
pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers {
|
pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers {
|
||||||
let mut complete = context.is_complete();
|
let mut complete = context.verified_answers_complete();
|
||||||
let mut fragments = Vec::new();
|
let mut fragments = Vec::new();
|
||||||
for answer in context.verified_answers() {
|
for answer in context.verified_answers() {
|
||||||
if let Some(text) = render_verified_answer(answer) {
|
if let Some(text) = render_verified_answer(answer) {
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ mod guardian_history;
|
|||||||
mod retained_context;
|
mod retained_context;
|
||||||
|
|
||||||
pub use retained_context::RetainedContext;
|
pub use retained_context::RetainedContext;
|
||||||
|
pub use retained_context::RetainedContextEntry;
|
||||||
pub use retained_context::RetainedContextEvent;
|
pub use retained_context::RetainedContextEvent;
|
||||||
|
pub use retained_context::RetainedUserMessage;
|
||||||
pub use retained_context::VerifiedAnswer;
|
pub use retained_context::VerifiedAnswer;
|
||||||
pub use retained_context::VerifiedQuestionAnswer;
|
pub use retained_context::VerifiedQuestionAnswer;
|
||||||
mod rollout_payload;
|
mod rollout_payload;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use schemars::JsonSchema;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
const MAX_ANSWERS: usize = 8;
|
const MAX_FAMILY_RECORDS: usize = 8;
|
||||||
const MAX_ANSWER_BYTES: usize = 16_384;
|
const MAX_RECORD_BYTES: usize = 16_384;
|
||||||
const MAX_ANSWERS_BYTES: usize = 65_536;
|
const MAX_FAMILY_BYTES: usize = 65_536;
|
||||||
|
|
||||||
/// Original assistant question and host-verified user reply, never an inferred permission.
|
/// Original assistant question and host-verified user reply, never an inferred permission.
|
||||||
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -27,6 +27,39 @@ pub struct VerifiedAnswer {
|
|||||||
pub questions: Vec<VerifiedQuestionAnswer>,
|
pub questions: Vec<VerifiedQuestionAnswer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Original user instruction, retained outside model summarization for delegated review.
|
||||||
|
/// Non-text input is not reconstructed as text; missing evidence remains explicit.
|
||||||
|
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct RetainedUserMessage {
|
||||||
|
pub turn_id: String,
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub text: String,
|
||||||
|
pub complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RetainedUserMessage {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("RetainedUserMessage")
|
||||||
|
.field("complete", &self.complete)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
struct Ordered<T> {
|
||||||
|
#[serde(default)]
|
||||||
|
order: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
value: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrowed host evidence in original arrival order, across retained families.
|
||||||
|
pub enum RetainedContextEntry<'a> {
|
||||||
|
UserMessage(&'a RetainedUserMessage),
|
||||||
|
VerifiedAnswer(&'a VerifiedAnswer),
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for VerifiedAnswer {
|
impl std::fmt::Debug for VerifiedAnswer {
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
formatter
|
formatter
|
||||||
@@ -47,9 +80,44 @@ pub enum RetainedContextEvent {
|
|||||||
/// Facts live until their source instruction is rolled back; compaction does not expire them.
|
/// Facts live until their source instruction is rolled back; compaction does not expire them.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct RetainedContext {
|
pub struct RetainedContext {
|
||||||
verified_answers: VecDeque<VerifiedAnswer>,
|
verified_answers: VecDeque<Ordered<VerifiedAnswer>>,
|
||||||
/// Lost evidence cannot be treated as a complete authorization history.
|
/// Lost evidence cannot be treated as a complete authorization history.
|
||||||
incomplete: bool,
|
#[serde(rename = "incomplete")]
|
||||||
|
verified_answers_incomplete: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
user_messages: VecDeque<Ordered<RetainedUserMessage>>,
|
||||||
|
/// Old checkpoints did not preserve user restrictions for delegated review.
|
||||||
|
#[serde(default = "legacy_user_messages_incomplete")]
|
||||||
|
user_messages_incomplete: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
next_order: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_user_messages_incomplete() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bound_family<T: Serialize>(items: &mut VecDeque<Ordered<T>>, incomplete: &mut bool) {
|
||||||
|
while items.len() > MAX_FAMILY_RECORDS
|
||||||
|
|| serde_json::to_vec(items).map_or(true, |bytes| bytes.len() > MAX_FAMILY_BYTES)
|
||||||
|
{
|
||||||
|
items.pop_front();
|
||||||
|
*incomplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetainedUserMessage {
|
||||||
|
fn bound(&mut self) {
|
||||||
|
if serde_json::to_vec(self).map_or(true, |bytes| bytes.len() > MAX_RECORD_BYTES) {
|
||||||
|
self.text.clear();
|
||||||
|
self.complete = false;
|
||||||
|
self.turn_id
|
||||||
|
.truncate(self.turn_id.floor_char_boundary(1_024));
|
||||||
|
if let Some(id) = &mut self.message_id {
|
||||||
|
id.truncate(id.floor_char_boundary(1_024));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RetainedContextEvent {
|
impl RetainedContextEvent {
|
||||||
@@ -57,7 +125,7 @@ impl RetainedContextEvent {
|
|||||||
pub fn bound(&mut self) {
|
pub fn bound(&mut self) {
|
||||||
match self {
|
match self {
|
||||||
Self::VerifiedAnswer(answer) => {
|
Self::VerifiedAnswer(answer) => {
|
||||||
if serde_json::to_vec(answer).map_or(true, |bytes| bytes.len() > MAX_ANSWER_BYTES) {
|
if serde_json::to_vec(answer).map_or(true, |bytes| bytes.len() > MAX_RECORD_BYTES) {
|
||||||
answer.questions.clear();
|
answer.questions.clear();
|
||||||
// IDs are correlation metadata, not model-visible authorization text.
|
// IDs are correlation metadata, not model-visible authorization text.
|
||||||
answer
|
answer
|
||||||
@@ -74,15 +142,67 @@ impl RetainedContextEvent {
|
|||||||
|
|
||||||
impl RetainedContext {
|
impl RetainedContext {
|
||||||
pub fn verified_answers(&self) -> impl DoubleEndedIterator<Item = &VerifiedAnswer> {
|
pub fn verified_answers(&self) -> impl DoubleEndedIterator<Item = &VerifiedAnswer> {
|
||||||
self.verified_answers.iter()
|
self.verified_answers.iter().map(|entry| &entry.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_complete(&self) -> bool {
|
pub fn verified_answers_complete(&self) -> bool {
|
||||||
!self.incomplete
|
!self.verified_answers_incomplete
|
||||||
&& self
|
&& self
|
||||||
.verified_answers
|
.verified_answers
|
||||||
.iter()
|
.iter()
|
||||||
.all(|answer| !answer.questions.is_empty())
|
.all(|answer| !answer.value.questions.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_messages_complete(&self) -> bool {
|
||||||
|
!self.user_messages_incomplete
|
||||||
|
&& self
|
||||||
|
.user_messages
|
||||||
|
.iter()
|
||||||
|
.all(|message| message.value.complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A skipped instruction leaves a gap that later checkpoints must preserve.
|
||||||
|
pub fn mark_user_messages_incomplete(&mut self) {
|
||||||
|
self.user_messages_incomplete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ordered_entries(&self) -> impl DoubleEndedIterator<Item = RetainedContextEntry<'_>> {
|
||||||
|
let mut entries = self
|
||||||
|
.verified_answers
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.order,
|
||||||
|
RetainedContextEntry::VerifiedAnswer(&entry.value),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.chain(
|
||||||
|
self.user_messages
|
||||||
|
.iter()
|
||||||
|
.map(|entry| (entry.order, RetainedContextEntry::UserMessage(&entry.value))),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort_by_key(|(order, _)| *order);
|
||||||
|
entries.into_iter().map(|(_, entry)| entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the already-persisted user item; checkpoint/suffix replay uses this same path.
|
||||||
|
pub fn record_user_message(&mut self, mut message: RetainedUserMessage) {
|
||||||
|
message.bound();
|
||||||
|
if let Some(index) = self.user_messages.iter().position(|entry| {
|
||||||
|
message.message_id.is_some() && entry.value.message_id == message.message_id
|
||||||
|
}) {
|
||||||
|
if self.user_messages[index].value == message {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.user_messages.remove(index);
|
||||||
|
}
|
||||||
|
self.user_messages.push_back(Ordered {
|
||||||
|
order: self.next_order,
|
||||||
|
value: message,
|
||||||
|
});
|
||||||
|
self.next_order = self.next_order.saturating_add(1);
|
||||||
|
bound_family(&mut self.user_messages, &mut self.user_messages_incomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same-event delivery is idempotent; changed contents replace that source's record.
|
/// Same-event delivery is idempotent; changed contents replace that source's record.
|
||||||
@@ -92,40 +212,81 @@ impl RetainedContext {
|
|||||||
match event {
|
match event {
|
||||||
RetainedContextEvent::VerifiedAnswer(answer) => {
|
RetainedContextEvent::VerifiedAnswer(answer) => {
|
||||||
if let Some(index) = self.verified_answers.iter().position(|existing| {
|
if let Some(index) = self.verified_answers.iter().position(|existing| {
|
||||||
existing.turn_id == answer.turn_id && existing.call_id == answer.call_id
|
existing.value.turn_id == answer.turn_id
|
||||||
|
&& existing.value.call_id == answer.call_id
|
||||||
}) {
|
}) {
|
||||||
if self.verified_answers[index] == answer {
|
if self.verified_answers[index].value == answer {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.verified_answers.remove(index);
|
self.verified_answers.remove(index);
|
||||||
}
|
}
|
||||||
self.verified_answers.push_back(answer);
|
self.verified_answers.push_back(Ordered {
|
||||||
while self.verified_answers.len() > MAX_ANSWERS
|
order: self.next_order,
|
||||||
|| serde_json::to_vec(&self.verified_answers)
|
value: answer,
|
||||||
.map_or(true, |bytes| bytes.len() > MAX_ANSWERS_BYTES)
|
});
|
||||||
{
|
self.next_order = self.next_order.saturating_add(1);
|
||||||
self.verified_answers.pop_front();
|
bound_family(
|
||||||
self.incomplete = true;
|
&mut self.verified_answers,
|
||||||
}
|
&mut self.verified_answers_incomplete,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restoring a saved thread must not bypass the live storage limits.
|
/// Restoring a saved thread must not bypass the live storage limits.
|
||||||
pub fn restore(&mut self, checkpoint: &Self) {
|
/// A missing checkpoint cannot establish complete historical user instructions.
|
||||||
*self = Self {
|
pub fn restore(&mut self, checkpoint: Option<&Self>) {
|
||||||
incomplete: checkpoint.incomplete,
|
*self = checkpoint.cloned().unwrap_or_else(|| Self {
|
||||||
|
user_messages_incomplete: true,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
};
|
});
|
||||||
for answer in &checkpoint.verified_answers {
|
for entry in &mut self.verified_answers {
|
||||||
self.record(&RetainedContextEvent::VerifiedAnswer(answer.clone()));
|
let mut event = RetainedContextEvent::VerifiedAnswer(entry.value.clone());
|
||||||
|
event.bound();
|
||||||
|
let RetainedContextEvent::VerifiedAnswer(answer) = event;
|
||||||
|
entry.value = answer;
|
||||||
|
self.next_order = self.next_order.max(entry.order.saturating_add(1));
|
||||||
}
|
}
|
||||||
|
for entry in &mut self.user_messages {
|
||||||
|
entry.value.bound();
|
||||||
|
self.next_order = self.next_order.max(entry.order.saturating_add(1));
|
||||||
|
}
|
||||||
|
bound_family(
|
||||||
|
&mut self.verified_answers,
|
||||||
|
&mut self.verified_answers_incomplete,
|
||||||
|
);
|
||||||
|
bound_family(&mut self.user_messages, &mut self.user_messages_incomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retains facts whose sources survive a lifecycle change. Compaction alone is not removal.
|
/// Keeps legacy answers whose source calls survive when no retained instruction boundary exists.
|
||||||
pub fn retain_answers(&mut self, keep: impl FnMut(&VerifiedAnswer) -> bool) {
|
pub fn retain_answers(&mut self, mut keep: impl FnMut(&VerifiedAnswer) -> bool) {
|
||||||
self.verified_answers.retain(keep);
|
self.verified_answers.retain(|answer| keep(&answer.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolls back at the original user-message boundary, including facts delivered after it.
|
||||||
|
/// Steering can share a turn ID. Legacy sources without message identity fall back to
|
||||||
|
/// source-turn removal and cannot establish complete retained user instructions.
|
||||||
|
pub fn rollback(&mut self, turn_ids: &[&str], first_removed_message_id: Option<&str>) {
|
||||||
|
if let Some(order) = first_removed_message_id.and_then(|id| {
|
||||||
|
self.user_messages
|
||||||
|
.iter()
|
||||||
|
.find(|message| message.value.message_id.as_deref() == Some(id))
|
||||||
|
.map(|message| message.order)
|
||||||
|
}) {
|
||||||
|
self.verified_answers.retain(|entry| entry.order < order);
|
||||||
|
self.user_messages.retain(|entry| entry.order < order);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.user_messages_incomplete |= first_removed_message_id.is_some()
|
||||||
|
|| self
|
||||||
|
.user_messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| turn_ids.contains(&message.value.turn_id.as_str()));
|
||||||
|
self.verified_answers
|
||||||
|
.retain(|answer| !turn_ids.contains(&answer.value.turn_id.as_str()));
|
||||||
|
self.user_messages
|
||||||
|
.retain(|message| !turn_ids.contains(&message.value.turn_id.as_str()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
#[test]
|
fn publish_answer() -> RetainedContextEvent {
|
||||||
fn answer_lifecycle_is_bounded_idempotent_and_checkpointed() {
|
RetainedContextEvent::VerifiedAnswer(VerifiedAnswer {
|
||||||
let mut context = RetainedContext::default();
|
|
||||||
let first = RetainedContextEvent::VerifiedAnswer(VerifiedAnswer {
|
|
||||||
turn_id: "turn-1".to_owned(),
|
turn_id: "turn-1".to_owned(),
|
||||||
call_id: "ask-1".to_owned(),
|
call_id: "ask-1".to_owned(),
|
||||||
questions: vec![VerifiedQuestionAnswer {
|
questions: vec![VerifiedQuestionAnswer {
|
||||||
question: "Publish?".to_owned(),
|
question: "Publish?".to_owned(),
|
||||||
answer: "Yes, but never publicly.".to_owned(),
|
answer: "Yes, but never publicly.".to_owned(),
|
||||||
}],
|
}],
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_evidence_preserves_order_through_checkpoint_and_rollback() {
|
||||||
|
let mut context = RetainedContext::default();
|
||||||
|
let first = publish_answer();
|
||||||
assert!(context.record(&first));
|
assert!(context.record(&first));
|
||||||
|
let before_restriction = context.clone();
|
||||||
|
context.record_user_message(RetainedUserMessage {
|
||||||
|
turn_id: "revocation-turn".to_owned(),
|
||||||
|
message_id: Some("revocation".to_owned()),
|
||||||
|
text: "Do not publish after all.".to_owned(),
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
let snapshot = context.clone();
|
let snapshot = context.clone();
|
||||||
assert!(!context.record(&first));
|
assert!(!context.record(&first));
|
||||||
assert_eq!(context, snapshot);
|
assert_eq!(context, snapshot);
|
||||||
@@ -20,8 +31,34 @@ fn answer_lifecycle_is_bounded_idempotent_and_checkpointed() {
|
|||||||
serde_json::from_str(&serde_json::to_string(&context).expect("retained answer fixture"))
|
serde_json::from_str(&serde_json::to_string(&context).expect("retained answer fixture"))
|
||||||
.expect("retained answer fixture");
|
.expect("retained answer fixture");
|
||||||
let mut restored = RetainedContext::default();
|
let mut restored = RetainedContext::default();
|
||||||
restored.restore(&checkpoint);
|
restored.restore(Some(&checkpoint));
|
||||||
assert_eq!(restored, snapshot);
|
assert_eq!(restored, snapshot);
|
||||||
|
assert_eq!(
|
||||||
|
restored
|
||||||
|
.ordered_entries()
|
||||||
|
.map(|entry| match entry {
|
||||||
|
RetainedContextEntry::VerifiedAnswer(answer) => answer.questions[0].answer.as_str(),
|
||||||
|
RetainedContextEntry::UserMessage(message) => message.text.as_str(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Yes, but never publicly.", "Do not publish after all."]
|
||||||
|
);
|
||||||
|
restored.rollback(&["revocation-turn"], Some("revocation"));
|
||||||
|
assert_eq!(
|
||||||
|
restored,
|
||||||
|
RetainedContext {
|
||||||
|
next_order: snapshot.next_order,
|
||||||
|
..before_restriction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_families_enforce_storage_limits_without_changing_snapshots() {
|
||||||
|
let mut context = RetainedContext::default();
|
||||||
|
let first = publish_answer();
|
||||||
|
context.record(&first);
|
||||||
|
let snapshot = context.clone();
|
||||||
|
|
||||||
for index in 2..=10 {
|
for index in 2..=10 {
|
||||||
context.record(&RetainedContextEvent::VerifiedAnswer(VerifiedAnswer {
|
context.record(&RetainedContextEvent::VerifiedAnswer(VerifiedAnswer {
|
||||||
@@ -33,17 +70,18 @@ fn answer_lifecycle_is_bounded_idempotent_and_checkpointed() {
|
|||||||
}],
|
}],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
assert!(!context.is_complete());
|
assert!(!context.verified_answers_complete());
|
||||||
assert_eq!(context.verified_answers().count(), MAX_ANSWERS);
|
assert_eq!(context.verified_answers().count(), MAX_FAMILY_RECORDS);
|
||||||
context.retain_answers(|answer| answer.turn_id != "turn-2");
|
context.rollback(&["turn-2"], /*first_removed_message_id*/ None);
|
||||||
assert_eq!(context.verified_answers().count(), 0);
|
assert_eq!(context.verified_answers().count(), 0);
|
||||||
assert!(!context.is_complete());
|
assert!(!context.verified_answers_complete());
|
||||||
|
|
||||||
|
let mut restored = snapshot.clone();
|
||||||
let mut oversized = first.clone();
|
let mut oversized = first.clone();
|
||||||
let RetainedContextEvent::VerifiedAnswer(answer) = &mut oversized;
|
let RetainedContextEvent::VerifiedAnswer(answer) = &mut oversized;
|
||||||
answer.questions[0].answer = "a".repeat(MAX_ANSWER_BYTES);
|
answer.questions[0].answer = "a".repeat(MAX_RECORD_BYTES);
|
||||||
restored.record(&oversized);
|
restored.record(&oversized);
|
||||||
assert!(!restored.is_complete());
|
assert!(!restored.verified_answers_complete());
|
||||||
assert!(
|
assert!(
|
||||||
restored
|
restored
|
||||||
.verified_answers()
|
.verified_answers()
|
||||||
@@ -61,4 +99,54 @@ fn answer_lifecycle_is_bounded_idempotent_and_checkpointed() {
|
|||||||
.answer,
|
.answer,
|
||||||
"Yes, but never publicly."
|
"Yes, but never publicly."
|
||||||
);
|
);
|
||||||
|
for index in 0..=MAX_FAMILY_RECORDS {
|
||||||
|
restored.record_user_message(RetainedUserMessage {
|
||||||
|
turn_id: "later-turn".to_owned(),
|
||||||
|
message_id: Some(format!("message-{index}")),
|
||||||
|
text: "Keep the repository private.".to_owned(),
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(!restored.user_messages_complete());
|
||||||
|
assert_eq!(
|
||||||
|
restored
|
||||||
|
.ordered_entries()
|
||||||
|
.filter(|entry| matches!(entry, RetainedContextEntry::UserMessage(_)))
|
||||||
|
.count(),
|
||||||
|
MAX_FAMILY_RECORDS
|
||||||
|
);
|
||||||
|
restored.record_user_message(RetainedUserMessage {
|
||||||
|
turn_id: "oversized-turn".to_owned(),
|
||||||
|
message_id: Some("oversized-message".to_owned()),
|
||||||
|
text: "restriction ".repeat(MAX_RECORD_BYTES),
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
let Some(RetainedContextEntry::UserMessage(message)) = restored.ordered_entries().next_back()
|
||||||
|
else {
|
||||||
|
panic!("latest user evidence");
|
||||||
|
};
|
||||||
|
assert_eq!((&message.text, message.complete), (&String::new(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_checkpoints_mark_user_messages_incomplete() {
|
||||||
|
let RetainedContextEvent::VerifiedAnswer(answer) = publish_answer();
|
||||||
|
let mut wire = serde_json::json!({
|
||||||
|
"verified_answers": [answer], "incomplete": false
|
||||||
|
});
|
||||||
|
let legacy: RetainedContext =
|
||||||
|
serde_json::from_value(wire.clone()).expect("legacy retained-answer checkpoint");
|
||||||
|
assert!(legacy.verified_answers_complete());
|
||||||
|
assert!(!legacy.user_messages_complete());
|
||||||
|
|
||||||
|
// Retain the original wire key even though the internal field names its family.
|
||||||
|
wire["verified_answers"][0]["order"] = serde_json::json!(0);
|
||||||
|
wire["user_messages"] = serde_json::json!([]);
|
||||||
|
wire["user_messages_incomplete"] = serde_json::json!(true);
|
||||||
|
wire["next_order"] = serde_json::json!(0);
|
||||||
|
assert_eq!(serde_json::to_value(&legacy).unwrap(), wire);
|
||||||
|
|
||||||
|
let mut restored = RetainedContext::default();
|
||||||
|
restored.restore(/*checkpoint*/ None);
|
||||||
|
assert!(!restored.user_messages_complete());
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ pub use codex_history::CompactedItem;
|
|||||||
pub use codex_history::InitialHistory;
|
pub use codex_history::InitialHistory;
|
||||||
pub use codex_history::ResponseItemEnvelope;
|
pub use codex_history::ResponseItemEnvelope;
|
||||||
pub use codex_history::ResumedHistory;
|
pub use codex_history::ResumedHistory;
|
||||||
|
pub use codex_history::RetainedContextEntry;
|
||||||
pub use codex_history::RetainedContextEvent;
|
pub use codex_history::RetainedContextEvent;
|
||||||
pub use codex_history::RolloutItem;
|
pub use codex_history::RolloutItem;
|
||||||
pub use codex_history::RolloutLine;
|
pub use codex_history::RolloutLine;
|
||||||
|
|||||||
@@ -4,16 +4,19 @@
|
|||||||
//! Most records happen to be ordered that way, but late completion events can target an older
|
//! Most records happen to be ordered that way, but late completion events can target an older
|
||||||
//! surviving turn after a newer turn has started. This planner keeps compact per-record ownership
|
//! surviving turn after a newer turn has started. This planner keeps compact per-record ownership
|
||||||
//! metadata for SQLite visibility, then combines it with `rollback_replay`'s cold-resume answer
|
//! metadata for SQLite visibility, then combines it with `rollback_replay`'s cold-resume answer
|
||||||
//! before the writer makes its second streaming pass.
|
//! before the writer makes its second streaming pass. Retained checkpoints are reduced when
|
||||||
//! Retained answers follow their source calls, not later steers sharing the same turn ID.
|
//! rollback is encountered, never by reapplying old rollbacks to later checkpoints.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use codex_protocol::ResponseItemId;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
use codex_rollout::CompactedItem;
|
use codex_rollout::CompactedItem;
|
||||||
|
use codex_rollout::RetainedContextEntry;
|
||||||
use codex_rollout::RolloutItem;
|
use codex_rollout::RolloutItem;
|
||||||
use codex_rollout::RolloutLine;
|
use codex_rollout::RolloutLine;
|
||||||
|
|
||||||
@@ -36,6 +39,12 @@ struct PendingUserResponse {
|
|||||||
content: Vec<ContentItem>,
|
content: Vec<ContentItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InstructionBoundary {
|
||||||
|
record_index: usize,
|
||||||
|
message_id: Option<ResponseItemId>,
|
||||||
|
alive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact plan keyed by parsed source-record index.
|
/// Compact plan keyed by parsed source-record index.
|
||||||
pub(super) struct RollbackPlan {
|
pub(super) struct RollbackPlan {
|
||||||
record_boundaries: Vec<Option<usize>>,
|
record_boundaries: Vec<Option<usize>>,
|
||||||
@@ -79,7 +88,7 @@ impl RollbackPlan {
|
|||||||
/// Streaming builder for RollbackPlan.
|
/// Streaming builder for RollbackPlan.
|
||||||
pub(super) struct RollbackPlanner {
|
pub(super) struct RollbackPlanner {
|
||||||
record_boundaries: Vec<Option<usize>>,
|
record_boundaries: Vec<Option<usize>>,
|
||||||
boundary_alive: Vec<bool>,
|
boundaries: Vec<InstructionBoundary>,
|
||||||
boundary_stack: Vec<usize>,
|
boundary_stack: Vec<usize>,
|
||||||
active_turn_id: Option<String>,
|
active_turn_id: Option<String>,
|
||||||
pending_turn_records: Vec<usize>,
|
pending_turn_records: Vec<usize>,
|
||||||
@@ -88,6 +97,7 @@ pub(super) struct RollbackPlanner {
|
|||||||
pending_delivery_boundary: Option<usize>,
|
pending_delivery_boundary: Option<usize>,
|
||||||
turn_boundaries: HashMap<String, usize>,
|
turn_boundaries: HashMap<String, usize>,
|
||||||
call_boundaries: HashMap<(String, String), Option<usize>>,
|
call_boundaries: HashMap<(String, String), Option<usize>>,
|
||||||
|
retained_fact_sources: Vec<(usize, String)>,
|
||||||
compactions: Vec<CompactionFrame>,
|
compactions: Vec<CompactionFrame>,
|
||||||
model_replay: ModelReplayPlanner,
|
model_replay: ModelReplayPlanner,
|
||||||
}
|
}
|
||||||
@@ -96,7 +106,7 @@ impl RollbackPlanner {
|
|||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
record_boundaries: Vec::new(),
|
record_boundaries: Vec::new(),
|
||||||
boundary_alive: Vec::new(),
|
boundaries: Vec::new(),
|
||||||
boundary_stack: Vec::new(),
|
boundary_stack: Vec::new(),
|
||||||
active_turn_id: None,
|
active_turn_id: None,
|
||||||
pending_turn_records: Vec::new(),
|
pending_turn_records: Vec::new(),
|
||||||
@@ -105,6 +115,7 @@ impl RollbackPlanner {
|
|||||||
pending_delivery_boundary: None,
|
pending_delivery_boundary: None,
|
||||||
turn_boundaries: HashMap::new(),
|
turn_boundaries: HashMap::new(),
|
||||||
call_boundaries: HashMap::new(),
|
call_boundaries: HashMap::new(),
|
||||||
|
retained_fact_sources: Vec::new(),
|
||||||
compactions: Vec::new(),
|
compactions: Vec::new(),
|
||||||
model_replay: ModelReplayPlanner::new(),
|
model_replay: ModelReplayPlanner::new(),
|
||||||
}
|
}
|
||||||
@@ -139,8 +150,10 @@ impl RollbackPlanner {
|
|||||||
RolloutItem::ResponseItem(response) => {
|
RolloutItem::ResponseItem(response) => {
|
||||||
if let Some(boundary) = paired_delivery_boundary {
|
if let Some(boundary) = paired_delivery_boundary {
|
||||||
self.record_boundaries[index] = Some(boundary);
|
self.record_boundaries[index] = Some(boundary);
|
||||||
|
self.boundaries[boundary].message_id = response.id().cloned();
|
||||||
} else if rollback::counts_as_boundary(&response.item) {
|
} else if rollback::counts_as_boundary(&response.item) {
|
||||||
let boundary = self.start_boundary(index);
|
let boundary = self.start_boundary(index);
|
||||||
|
self.boundaries[boundary].message_id = response.id().cloned();
|
||||||
if let ResponseItem::Message { role, content, .. } = &response.item
|
if let ResponseItem::Message { role, content, .. } = &response.item
|
||||||
&& role == "user"
|
&& role == "user"
|
||||||
{
|
{
|
||||||
@@ -247,6 +260,8 @@ impl RollbackPlanner {
|
|||||||
}
|
}
|
||||||
self.call_boundaries
|
self.call_boundaries
|
||||||
.insert(source, self.record_boundaries[index]);
|
.insert(source, self.record_boundaries[index]);
|
||||||
|
self.retained_fact_sources
|
||||||
|
.push((index, answer.turn_id.clone()));
|
||||||
}
|
}
|
||||||
RolloutItem::SecurityRiskScore(_) => self.record_boundaries[index] = None,
|
RolloutItem::SecurityRiskScore(_) => self.record_boundaries[index] = None,
|
||||||
}
|
}
|
||||||
@@ -257,34 +272,19 @@ impl RollbackPlanner {
|
|||||||
pub(super) fn finish(self) -> RollbackPlan {
|
pub(super) fn finish(self) -> RollbackPlan {
|
||||||
let RollbackPlanner {
|
let RollbackPlanner {
|
||||||
record_boundaries,
|
record_boundaries,
|
||||||
boundary_alive,
|
boundaries,
|
||||||
compactions,
|
compactions,
|
||||||
model_replay,
|
model_replay,
|
||||||
turn_boundaries,
|
|
||||||
call_boundaries,
|
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
let replay_anchor = model_replay.finish().empty_replacement_history_compaction;
|
let replay_anchor = model_replay.finish().empty_replacement_history_compaction;
|
||||||
let removed_turns = turn_boundaries
|
let boundary_alive = boundaries
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter(|(_, boundary)| !boundary_alive[**boundary])
|
.map(|boundary| boundary.alive)
|
||||||
.map(|(turn_id, _)| turn_id.as_str())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let compacted_items = compactions
|
let compacted_items = compactions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|mut frame| {
|
.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.retain_answers(|answer| {
|
|
||||||
call_boundaries
|
|
||||||
.get(&(answer.turn_id.clone(), answer.call_id.clone()))
|
|
||||||
.map_or_else(
|
|
||||||
|| !removed_turns.contains(&answer.turn_id.as_str()),
|
|
||||||
|boundary| boundary.is_none_or(|boundary| boundary_alive[boundary]),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if Some(frame.record_index) == replay_anchor {
|
if Some(frame.record_index) == replay_anchor {
|
||||||
frame.item.replacement_history = Some(Vec::new());
|
frame.item.replacement_history = Some(Vec::new());
|
||||||
frame.item.mcp_resource_origins = None;
|
frame.item.mcp_resource_origins = None;
|
||||||
@@ -304,8 +304,12 @@ impl RollbackPlanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_boundary(&mut self, index: usize) -> usize {
|
fn start_boundary(&mut self, index: usize) -> usize {
|
||||||
let boundary = self.boundary_alive.len();
|
let boundary = self.boundaries.len();
|
||||||
self.boundary_alive.push(true);
|
self.boundaries.push(InstructionBoundary {
|
||||||
|
record_index: index,
|
||||||
|
message_id: None,
|
||||||
|
alive: true,
|
||||||
|
});
|
||||||
let had_prior_boundary = !self.boundary_stack.is_empty();
|
let had_prior_boundary = !self.boundary_stack.is_empty();
|
||||||
if had_prior_boundary {
|
if had_prior_boundary {
|
||||||
for pending_index in self.pending_context_records.drain(..) {
|
for pending_index in self.pending_context_records.drain(..) {
|
||||||
@@ -348,16 +352,72 @@ impl RollbackPlanner {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let depth_before = self.boundary_stack.len();
|
let depth_before = self.boundary_stack.len();
|
||||||
|
let mut removed_boundaries = HashSet::new();
|
||||||
|
let mut first_removed_boundary = None;
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let Some(boundary) = self.boundary_stack.pop() else {
|
let Some(boundary) = self.boundary_stack.pop() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
self.boundary_alive[boundary] = false;
|
self.boundaries[boundary].alive = false;
|
||||||
|
removed_boundaries.insert(boundary);
|
||||||
|
first_removed_boundary = Some(boundary);
|
||||||
|
}
|
||||||
|
if let Some(boundary) = first_removed_boundary {
|
||||||
|
let source = &self.boundaries[boundary];
|
||||||
|
let removed_turns = self
|
||||||
|
.turn_boundaries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, boundary)| removed_boundaries.contains(*boundary))
|
||||||
|
.map(|(turn_id, _)| turn_id.as_str())
|
||||||
|
.chain(
|
||||||
|
self.retained_fact_sources
|
||||||
|
.iter()
|
||||||
|
.filter(|(index, _)| {
|
||||||
|
self.record_boundaries[*index]
|
||||||
|
.is_some_and(|boundary| removed_boundaries.contains(&boundary))
|
||||||
|
})
|
||||||
|
.map(|(_, turn_id)| turn_id.as_str()),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
// A checkpoint before this instruction cannot contain evidence from it.
|
||||||
|
// Later checkpoints have not been observed yet and already reflect this rollback.
|
||||||
|
for frame in self
|
||||||
|
.compactions
|
||||||
|
.iter_mut()
|
||||||
|
.rev()
|
||||||
|
.take_while(|frame| frame.record_index >= source.record_index)
|
||||||
|
{
|
||||||
|
if let Some(context) = &mut frame.item.retained_context {
|
||||||
|
if context
|
||||||
|
.ordered_entries()
|
||||||
|
.any(|entry| matches!(entry, RetainedContextEntry::UserMessage(_)))
|
||||||
|
{
|
||||||
|
context.rollback(
|
||||||
|
&removed_turns,
|
||||||
|
source.message_id.as_ref().map(ResponseItemId::as_str),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Checkpoints written without instruction retention keep the legacy
|
||||||
|
// source-call boundary, including answers before a same-turn steer.
|
||||||
|
context.retain_answers(|answer| {
|
||||||
|
self.call_boundaries
|
||||||
|
.get(&(answer.turn_id.clone(), answer.call_id.clone()))
|
||||||
|
.map_or_else(
|
||||||
|
|| !removed_turns.contains(&answer.turn_id.as_str()),
|
||||||
|
|boundary| {
|
||||||
|
boundary
|
||||||
|
.is_none_or(|boundary| self.boundaries[boundary].alive)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let compaction_index = self.compactions.iter().rposition(|frame| {
|
let compaction_index = self.compactions.iter().rposition(|frame| {
|
||||||
frame
|
frame
|
||||||
.owner
|
.owner
|
||||||
.is_none_or(|boundary| self.boundary_alive[boundary])
|
.is_none_or(|boundary| self.boundaries[boundary].alive)
|
||||||
});
|
});
|
||||||
if let Some(compaction_index) = compaction_index {
|
if let Some(compaction_index) = compaction_index {
|
||||||
let frame = &mut self.compactions[compaction_index];
|
let frame = &mut self.compactions[compaction_index];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use codex_extension_items::ExtensionItem;
|
|||||||
use codex_extension_items::image_generation::ImageGenerationFailure;
|
use codex_extension_items::image_generation::ImageGenerationFailure;
|
||||||
use codex_extension_items::image_generation::ImageGenerationItem;
|
use codex_extension_items::image_generation::ImageGenerationItem;
|
||||||
use codex_protocol::AgentPath;
|
use codex_protocol::AgentPath;
|
||||||
|
use codex_protocol::ResponseItemId;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::config_types::ReasoningSummary;
|
use codex_protocol::config_types::ReasoningSummary;
|
||||||
use codex_protocol::items::ReasoningItem;
|
use codex_protocol::items::ReasoningItem;
|
||||||
@@ -18,6 +19,8 @@ use codex_protocol::items::TurnItem;
|
|||||||
use codex_protocol::mcp::McpResourceOrigin;
|
use codex_protocol::mcp::McpResourceOrigin;
|
||||||
use codex_protocol::mcp::McpResourceOriginCheckpoint;
|
use codex_protocol::mcp::McpResourceOriginCheckpoint;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
|
use codex_protocol::models::ContentItemKind;
|
||||||
|
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::protocol::AgentMessageEvent;
|
use codex_protocol::protocol::AgentMessageEvent;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
@@ -1267,6 +1270,139 @@ async fn migration_preserves_reverse_replay_anchor_after_pre_compaction_rollback
|
|||||||
assert!(replacement_history.is_empty());
|
assert!(replacement_history.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_preserves_same_turn_evidence_across_rollback_checkpoints() {
|
||||||
|
const INITIAL: &str = "Never publish publicly.";
|
||||||
|
const STEER: &str = "Also inspect the README.";
|
||||||
|
let home = TempDir::new().expect("create Codex home");
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
let [initial, steer] =
|
||||||
|
[("initial", INITIAL), ("steer", STEER)].map(|(id, text)| ResponseItem::Message {
|
||||||
|
id: Some(ResponseItemId::with_suffix("msg", id)),
|
||||||
|
role: "user".to_owned(),
|
||||||
|
content: vec![ContentItem::InputText {
|
||||||
|
text: text.to_owned(),
|
||||||
|
}],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: Some(
|
||||||
|
InternalChatMessageMetadataPassthrough {
|
||||||
|
turn_id: Some("shared-turn".to_owned()),
|
||||||
|
content_item_kinds: Some(vec![ContentItemKind("user.text".to_owned())]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let RolloutItem::Compacted(mut checkpoint) = compacted(vec![initial.clone(), steer.clone()])
|
||||||
|
else {
|
||||||
|
unreachable!("compacted helper creates a checkpoint");
|
||||||
|
};
|
||||||
|
let retained = json!({
|
||||||
|
"user_messages": [
|
||||||
|
{"order": 0, "turn_id": "shared-turn", "message_id": "msg_initial", "text": INITIAL, "complete": true},
|
||||||
|
{"order": 2, "turn_id": "shared-turn", "message_id": "msg_steer", "text": STEER, "complete": true}
|
||||||
|
],
|
||||||
|
"verified_answers": [
|
||||||
|
{"order": 1, "turn_id": "shared-turn", "call_id": "before", "questions": [{"question": "Publish?", "answer": "Only privately."}]},
|
||||||
|
{"order": 3, "turn_id": "shared-turn", "call_id": "after", "questions": [{"question": "Publish the README?", "answer": "Do not publish it."}]}
|
||||||
|
],
|
||||||
|
"incomplete": false, "user_messages_incomplete": false, "next_order": 4
|
||||||
|
});
|
||||||
|
checkpoint.retained_context =
|
||||||
|
Some(serde_json::from_value(retained.clone()).expect("retained fixture"));
|
||||||
|
let answers = checkpoint
|
||||||
|
.retained_context
|
||||||
|
.as_ref()
|
||||||
|
.expect("retained checkpoint")
|
||||||
|
.verified_answers()
|
||||||
|
.cloned()
|
||||||
|
.map(codex_rollout::RetainedContextEvent::VerifiedAnswer)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut expected = retained;
|
||||||
|
expected["user_messages"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("user messages")
|
||||||
|
.truncate(/*len*/ 1);
|
||||||
|
expected["verified_answers"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("verified answers")
|
||||||
|
.truncate(/*len*/ 1);
|
||||||
|
let mut before_expected = expected.clone();
|
||||||
|
before_expected["next_order"] = json!(2);
|
||||||
|
let mut before_steer = checkpoint.clone();
|
||||||
|
before_steer.replacement_history = Some(vec![initial.clone().into()]);
|
||||||
|
before_steer.retained_context =
|
||||||
|
Some(serde_json::from_value(before_expected.clone()).expect("pre-steer checkpoint"));
|
||||||
|
let mut after_rollback = before_steer.clone();
|
||||||
|
after_rollback.retained_context =
|
||||||
|
Some(serde_json::from_value(expected.clone()).expect("post-rollback checkpoint"));
|
||||||
|
let path = write_rollout(
|
||||||
|
home.path(),
|
||||||
|
thread_id,
|
||||||
|
SessionSource::Cli,
|
||||||
|
vec![
|
||||||
|
started("shared-turn"),
|
||||||
|
rollout_response_item(initial.clone()),
|
||||||
|
user_message(INITIAL),
|
||||||
|
RolloutItem::RetainedContext(answers[0].clone()),
|
||||||
|
RolloutItem::Compacted(before_steer),
|
||||||
|
rollout_response_item(steer),
|
||||||
|
user_message(STEER),
|
||||||
|
RolloutItem::RetainedContext(answers[1].clone()),
|
||||||
|
completed("shared-turn"),
|
||||||
|
started("compaction-turn"),
|
||||||
|
RolloutItem::Compacted(checkpoint),
|
||||||
|
completed("compaction-turn"),
|
||||||
|
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||||
|
num_turns: 1,
|
||||||
|
})),
|
||||||
|
// This checkpoint already contains the correct live rollback result.
|
||||||
|
started("post-rollback-compaction"),
|
||||||
|
RolloutItem::Compacted(after_rollback),
|
||||||
|
completed("post-rollback-compaction"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let store = indexed_store(home.path()).await;
|
||||||
|
store
|
||||||
|
.migrate_rollouts(apply_options())
|
||||||
|
.await
|
||||||
|
.expect("migrate same-turn rollback");
|
||||||
|
let migrated = read_rollout(&path);
|
||||||
|
let checkpoints = migrated
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| match &line.item {
|
||||||
|
RolloutItem::Compacted(item) => Some(item),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
checkpoints
|
||||||
|
.iter()
|
||||||
|
.map(
|
||||||
|
|checkpoint| serde_json::to_value(&checkpoint.retained_context)
|
||||||
|
.expect("retained context")
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![before_expected, expected.clone(), expected]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
checkpoints
|
||||||
|
.last()
|
||||||
|
.expect("latest checkpoint")
|
||||||
|
.replacement_history,
|
||||||
|
Some(vec![initial.into()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
migrated
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|line| match line.item {
|
||||||
|
RolloutItem::RetainedContext(event) => Some(event),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![answers[0].clone()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn migration_keeps_empty_replay_anchor_from_rolled_back_turn() {
|
async fn migration_keeps_empty_replay_anchor_from_rolled_back_turn() {
|
||||||
let home = TempDir::new().expect("create Codex home");
|
let home = TempDir::new().expect("create Codex home");
|
||||||
|
|||||||
Reference in new issue
Block a user