Preserve encrypted agent messages in Guardian reviews (#49038)

## Why

Guardian transcripts omitted encrypted agent messages, leaving reviews without evidence such as encrypted parent replies.

## What changed

- Carry encrypted `agent_message` items through synchronous reviews and asynchronous scoring, preserving their order, author, recipient, and ciphertext.
- Account for native messages in context budgets and omit them whole with an explicit notice when limits require it.
- Support mixed text and native messages during review admission while retaining user-content annotations.

## Testing

Add coverage for transcript ordering and budget eviction in both reviewer profiles, plus a mocked scenario verifying that an encrypted parent reply appears exactly once in each of two incremental Guardian reviews.

GitOrigin-RevId: abc2f1ce5accd83beb06f799ef9ed9d7fa2cd90a
This commit is contained in:
felixxia-oai
2026-09-28 17:40:25 +00:00
committed by copyberry
parent 64bf4e7e62
commit 368e5eae2f
22 changed files with 795 additions and 106 deletions
@@ -66,29 +66,32 @@ fn heartbeat_references_preserve_positions_changes_and_human_messages() {
);
}
for index in [1, 3] {
assert!(
entries[index]
.text
.contains("unchanged from transcript entry [1]")
);
assert!(!entries[index].text.contains("Monitor only."));
let crate::TranscriptContent::Text(text) = &entries[index].content else {
panic!("text entry")
};
assert!(text.contains("unchanged from transcript entry [1]"));
assert!(!text.contains("Monitor only."));
}
// A rebuilt window starts with a full body, never a dangling old reference.
let rebuilt = collect_transcript(&history[3..].to_vec(), &transcript_config());
assert!(rebuilt[0].text.contains("Monitor only."));
assert!(
matches!(&rebuilt[0].content, crate::TranscriptContent::Text(text) if text.contains("Monitor only."))
);
let interrupted = vec![
history[0].clone(),
make("02:00Z", &"x".repeat(/*n*/ 3_600), "user.heartbeat"),
history[1].clone(),
];
let interrupted = collect_transcript(&interrupted, &transcript_config());
assert!(interrupted[2].text.contains("Monitor only."));
assert!(
matches!(&interrupted[2].content, crate::TranscriptContent::Text(text) if text.contains("Monitor only."))
);
}
fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTranscriptEntry {
ConversationTranscriptEntry {
kind,
text: text.to_string(),
content: crate::TranscriptContent::Text(text.to_string()),
original_bytes: text.len(),
retained_source: None,
}
@@ -428,7 +431,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() {
transcript_items(&sections[0]),
vec![ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::User,
text: text.clone(),
content: crate::TranscriptContent::Text(text.clone()),
original_bytes: text.len(),
retained_source: None,
}]
@@ -466,7 +469,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() {
.expect("transcript collection should succeed");
let mut expected = vec![ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::User,
text: text.clone(),
content: crate::TranscriptContent::Text(text.clone()),
original_bytes: text.len(),
retained_source: None,
}];
@@ -475,7 +478,9 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() {
kind: ConversationTranscriptEntryKind::ToolCall(
"tool exec_command call".to_string(),
),
text: truncate_text(&text, /*max_tokens*/ 30),
content: crate::TranscriptContent::Text(truncate_text(
&text, /*max_tokens*/ 30,
)),
original_bytes: text.len(),
retained_source: None,
});
@@ -490,3 +495,81 @@ fn transcript_items(section: &ContextSection) -> &[ConversationTranscriptEntry]
};
items
}
#[test]
fn encrypted_messages_preserve_order_and_budget_for_both_reviewers() {
use crate::ContextPresentation;
use crate::ContextProfile;
use crate::HistoryTruncation;
use crate::RequestBudget;
use codex_protocol::models::AgentMessageInputContent;
let message = ResponseItem::AgentMessage {
id: None,
author: "/root".into(),
recipient: "/root/worker".into(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: "opaque-parent-reply".repeat(/*n*/ 80),
}],
internal_chat_message_metadata_passthrough: None,
};
let user = ResponseItem::Message {
id: None,
role: "user".into(),
content: vec![ContentItem::InputText {
text: "Update this page.".into(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
};
let history = vec![user.clone(), message.clone(), user];
for profile in [
ContextProfile::synchronous(),
ContextProfile::asynchronous(),
] {
let entries = collect_transcript(&history, &profile.transcript);
let context = crate::CollectedContext {
sections: vec![ContextSection::ConversationTranscript {
items: entries.clone(),
}],
}
.compose(
ContextPresentation::SyncFull {
session_id: "worker",
},
profile.render_transcript(&entries, /*entry_number_offset*/ 0),
)
.unwrap();
assert_eq!(
context
.section_costs()
.filter(|(name, _)| *name == "conversation_transcript")
.count(),
1
);
let messages = context.clone().into_messages();
assert_eq!(messages.len(), 3);
assert_eq!(messages[1], message);
// Native messages must be evicted whole, with an explicit omission notice.
let budget = context.estimated_tokens() - crate::estimate_input_tokens(&message) + 100;
let reduced = context
.enforce_budget(
RequestBudget {
max_input_tokens: budget,
existing_context_tokens: 0,
},
"Evidence omitted.".into(),
HistoryTruncation::Preserve,
)
.unwrap();
assert!(reduced.estimated_tokens() <= budget);
let reduced = serde_json::to_value(reduced.into_messages()).unwrap();
assert!(reduced.to_string().contains("Evidence omitted."));
assert!(!reduced.to_string().contains("opaque-parent-reply"));
let mut limited = profile.transcript;
limited.entry_limits.message_tokens = 10;
let entries = collect_transcript(&history, &limited);
assert!(
matches!(&entries[1].content, crate::TranscriptContent::Text(text) if text.contains("omitted"))
);
}
}