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."