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:
jif
2026-10-15 12:56:39 +00:00
committed by copyberry
parent 5fff6378d6
commit 3ee47c1051
3 changed files with 120 additions and 1 deletions
@@ -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)]
+15 -1
View File
@@ -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."