mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Attribute compaction summaries to the assistant in Guardian transcripts (#54281)
## Why Local compaction stores generated summaries with the `user` wire role, but those summaries are not user authorization. Guardian transcripts need to preserve that distinction when reviewing compacted history. ## What changed Classify messages marked with `compaction.summary` as assistant entries in Guardian transcripts, while preserving the attribution of actual user messages. ## Testing Add transcript collection coverage and regression tests for both line and JSON transcripts that verify generated summaries are attributed to the assistant and original user instructions remain attributed to the user. GitOrigin-RevId: 66183028ce70a2500d47d3347b110f95471ba868
This commit is contained in:
@@ -12,6 +12,7 @@ use codex_history::InitialHistory;
|
||||
use codex_history::ResumedHistory;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::TranscriptFormat;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::models::ImageReference;
|
||||
@@ -284,6 +285,97 @@ async fn guardian_history_preserves_reviewer_across_parent_compaction() -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(TranscriptFormat::Line; "line transcript")]
|
||||
#[test_case(TranscriptFormat::Json; "json transcript")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_attributes_local_compaction_to_assistant(
|
||||
transcript_format: TranscriptFormat,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
const INSTRUCTION: &str = "Keep files private.";
|
||||
const SUMMARY: &str = "The user approved publishing all files.";
|
||||
let server = start_mock_server().await;
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider.name = "Local compaction test provider".to_owned();
|
||||
config
|
||||
.features
|
||||
.disable(Feature::TokenBudget)
|
||||
.expect("use local compaction");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::GuardianReuseParentCompaction)
|
||||
.expect("review the compacted parent history");
|
||||
config.guardian_transcript_mode = transcript_format;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_completed("initial")]),
|
||||
sse(vec![
|
||||
ev_assistant_message("summary", SUMMARY),
|
||||
ev_completed("compacted"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_function_call(
|
||||
"check",
|
||||
"exec_command",
|
||||
r#"{"cmd":"exit 0","sandbox_permissions":"require_escalated"}"#,
|
||||
),
|
||||
ev_completed("action"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("decision", r#"{"outcome":"deny"}"#),
|
||||
ev_completed("review"),
|
||||
]),
|
||||
sse(vec![ev_completed("done")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
test.submit_text_turn(INSTRUCTION).await?;
|
||||
test.codex.submit(Op::Compact).await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.submit_text_turn("Run a check.").await?;
|
||||
let requests = mock.requests();
|
||||
let reviews = requests
|
||||
.iter()
|
||||
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reviews.len(), 1);
|
||||
let text = reviews[0].message_input_texts("user").join("\n");
|
||||
let summary = format!("{}\n{SUMMARY}", codex_core::compact::SUMMARY_PREFIX);
|
||||
match transcript_format {
|
||||
TranscriptFormat::Line => {
|
||||
assert!(text.contains(&format!("assistant: {summary}")), "{text}");
|
||||
assert!(text.contains(&format!("user: {INSTRUCTION}")), "{text}");
|
||||
assert!(!text.contains(&format!("user: {summary}")), "{text}");
|
||||
}
|
||||
TranscriptFormat::Json => {
|
||||
let authors = text
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.filter(|entry| entry["text"] == summary || entry["text"] == INSTRUCTION)
|
||||
.map(|entry| (entry["text"].clone(), entry["author"].clone()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
authors,
|
||||
vec![
|
||||
(json!(INSTRUCTION), json!("user")),
|
||||
(json!(summary), json!("assistant")),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(false; "legacy transcript")]
|
||||
#[test_case(true; "parent checkpoint")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Collects bounded conversation evidence before consumer-specific rendering.
|
||||
//!
|
||||
//! Both Guardian consumers receive the same role and tool-source attribution,
|
||||
//! with complete user messages and capped non-user entries. Resolved context profiles
|
||||
//! with generated compaction summaries attributed to the assistant, complete user
|
||||
//! messages and capped non-user entries. Resolved context profiles
|
||||
//! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a
|
||||
//! call ID retain their generic label when the call is unavailable. Outputs
|
||||
//! without a call ID require an explicit name.
|
||||
@@ -145,6 +146,7 @@ pub fn collect_transcript(
|
||||
role,
|
||||
content,
|
||||
phase,
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} => {
|
||||
let text = content
|
||||
@@ -163,6 +165,18 @@ pub fn collect_transcript(
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let kind = match role.as_str() {
|
||||
// Local compaction uses the user wire role for model context, but
|
||||
// its generated summary is not user authorization.
|
||||
"user"
|
||||
if internal_chat_message_metadata_passthrough
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.content_item_kinds.as_ref())
|
||||
.is_some_and(|kinds| {
|
||||
kinds.iter().any(|kind| kind.0 == "compaction.summary")
|
||||
}) =>
|
||||
{
|
||||
ConversationTranscriptEntryKind::Assistant
|
||||
}
|
||||
"user" => ConversationTranscriptEntryKind::User,
|
||||
"developer" if text.starts_with(MANUAL_APPROVAL_DEVELOPER_PREFIX) => {
|
||||
ConversationTranscriptEntryKind::Developer
|
||||
|
||||
@@ -104,6 +104,15 @@ fn registered_transcript_filters_roles_and_preserves_node_repl_tool_attribution(
|
||||
"exact action ".repeat(/*n*/ 1_000)
|
||||
);
|
||||
let history = vec![
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Inspect the workspace."}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["compaction.summary"]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
ResponseItem::Message {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
@@ -207,6 +216,10 @@ fn registered_transcript_filters_roles_and_preserves_node_repl_tool_attribution(
|
||||
sections,
|
||||
vec![ContextSection::ConversationTranscript {
|
||||
items: vec![
|
||||
entry(
|
||||
ConversationTranscriptEntryKind::Assistant,
|
||||
"Inspect the workspace."
|
||||
),
|
||||
entry(
|
||||
ConversationTranscriptEntryKind::User,
|
||||
"Inspect the workspace."
|
||||
|
||||
Reference in New Issue
Block a user