From 3ee47c10513f1bb57d869ea650aba28638e3d11c Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 15 Oct 2026 12:50:35 +0000 Subject: [PATCH] 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 --- codex-rs/core/tests/suite/guardian_history.rs | 92 +++++++++++++++++++ codex-rs/guardian-context/src/transcript.rs | 16 +++- .../guardian-context/src/transcript_tests.rs | 13 +++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/guardian_history.rs b/codex-rs/core/tests/suite/guardian_history.rs index dcb0cc7c72..adc2c5bb56 100644 --- a/codex-rs/core/tests/suite/guardian_history.rs +++ b/codex-rs/core/tests/suite/guardian_history.rs @@ -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::>(); + 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::(line).ok()) + .filter(|entry| entry["text"] == summary || entry["text"] == INSTRUCTION) + .map(|entry| (entry["text"].clone(), entry["author"].clone())) + .collect::>(); + 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)] diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index 826c171229..b3f7e375d3 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -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::>() .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 diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index 27eb7d4d11..7ac7fa0edc 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -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."