diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7cc907203b..1e546d90e5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3049,6 +3049,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 37c14ab72d..f50f76d4e7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15455,6 +15455,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9aac3eb2af..00375ed018 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11467,6 +11467,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 7704081998..108ebe232e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -165,6 +165,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index db7a0ac431..e953db7aa3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -832,6 +832,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index dec11ca9d7..d99230ee4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -936,6 +936,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 03a4d8ddb7..00fc581f9d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index aec018030c..b9f59501d0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 32c44a7fe5..caa6edca81 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 90f142dce6..dfba3c1c99 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -952,6 +952,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index cbf3223c56..80cc673e6b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index ef13faffe3..3e45ae081a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -936,6 +936,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 568eb2211d..54bc31ab0e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 4db744c11a..687e64c5c6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -832,6 +832,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 7416e5b4a2..a0648ceeab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -858,6 +858,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 70487c9d29..6d9605d2ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -832,6 +832,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 047017e9b1..dc7dc549fe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -832,6 +832,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 5ea7ace499..32ae5760ea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -832,6 +832,13 @@ "null" ] }, + "reviewTarget": { + "description": "Opaque server-issued block target. Presence alone does not enable target-based continuation.", + "type": [ + "string", + "null" + ] + }, "steer": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 34523e9766..951bb6b29f 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 61346229fb..518a22048a 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MisalignmentErrorDetails.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MisalignmentErrorDetails.ts index 3f6e449dc5..bb77056771 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/MisalignmentErrorDetails.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MisalignmentErrorDetails.ts @@ -15,4 +15,8 @@ detailedExplanation: string | null, /** * Instruction to submit as the next turn's user input if continuation is confirmed. */ -steer: MisalignmentSteer | null, }; +steer: MisalignmentSteer | null, +/** + * Opaque server-issued block target. Presence alone does not enable target-based continuation. + */ +reviewTarget?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index e870ec4e9c..0f86001aed 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2410,7 +2410,9 @@ mod tests { == Path::new("v2/GetAccountTokenUsageResponse.ts") && field_prefix.trim() == "threadUsage?") || (path == Path::new("v2/McpServerOauthLoginCompletedNotification.ts") - && field_prefix.trim() == "loginId?"); + && field_prefix.trim() == "loginId?") + || (path == Path::new("v2/MisalignmentErrorDetails.ts") + && field_prefix.trim() == "reviewTarget?"); if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') && !allow_optional_nullable && !legacy_optional_nullable_field diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 5f5bcce4bc..e54fa77765 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -442,6 +442,9 @@ pub struct MisalignmentErrorDetails { pub detailed_explanation: Option, /// Instruction to submit as the next turn's user input if continuation is confirmed. pub steer: Option, + /// Opaque server-issued block target. Presence alone does not enable target-based continuation. + #[ts(optional = nullable)] + pub review_target: Option, } impl fmt::Debug for MisalignmentErrorDetails { @@ -454,6 +457,7 @@ impl fmt::Debug for MisalignmentErrorDetails { &self.detailed_explanation.is_some(), ) .field("has_steer", &self.steer.is_some()) + .field("has_review_target", &self.review_target.is_some()) .finish() } } @@ -464,6 +468,7 @@ impl From for MisalignmentErrorDetails { error_type: value.error_type, detailed_explanation: value.detailed_explanation, steer: value.steer.map(Into::into), + review_target: value.review_target, } } } diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6c0ac094bd..1d22a7a8f6 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -490,6 +490,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() let start_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("source".to_string(), "initial-source".to_string()), + ( + "misalignment_override".to_string(), + r#"{"review_target":"RB._~:-opaque"}"#.to_string(), + ), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -519,6 +523,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() ("fiber_run_id".to_string(), "fiber-steer-456".to_string()), ("origin".to_string(), "gaas".to_string()), ("source".to_string(), "steer-source".to_string()), + ( + "misalignment_override".to_string(), + r#"{"review_target":"RC"}"#.to_string(), + ), ]); let steer_req = mcp .send_turn_steer_request(TurnSteerParams { @@ -570,6 +578,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() assert_eq!(second_metadata["turn_id"].as_str(), Some(turn_id.as_str())); assert_eq!(second_metadata["turn_trigger"].as_str(), Some("user")); assert_eq!(second_metadata["source"].as_str(), Some("steer-source")); + assert_eq!( + second_metadata["misalignment_override"], + first_metadata["misalignment_override"], + ); let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; assert_eq!( diff --git a/codex-rs/app-server/tests/suite/v2/misalignment_policy.rs b/codex-rs/app-server/tests/suite/v2/misalignment_policy.rs index 131911b0c1..7c522083df 100644 --- a/codex-rs/app-server/tests/suite/v2/misalignment_policy.rs +++ b/codex-rs/app-server/tests/suite/v2/misalignment_policy.rs @@ -24,6 +24,7 @@ use wiremock::ResponseTemplate; const MESSAGE: &str = "This request violated the misalignment policy."; const EXPLANATION: &str = "The agent attempted to transfer your files externally."; const STEER: &str = "Continue without transferring the user's files externally."; +const REVIEW_TARGET: &str = "RB._~:-opaque"; #[tokio::test] async fn streamed_policy_violation_completes_turn_with_typed_terminal_error() -> Result<()> { @@ -104,6 +105,7 @@ async fn streamed_policy_violation_exposes_resumable_misalignment_details() -> R "misalignment": { "error_type": "unauthorized_data_transfer", "detailed_explanation": EXPLANATION, + "review_target": REVIEW_TARGET, "steer": { "message": STEER } } } @@ -129,6 +131,7 @@ async fn http_403_policy_violation_exposes_resumable_misalignment_details() -> R "misalignment": { "error_type": "unauthorized_data_transfer", "detailed_explanation": EXPLANATION, + "review_target": REVIEW_TARGET, "steer": { "message": STEER } } } @@ -165,6 +168,7 @@ async fn classification_only_policy_violation_preserves_the_terminal_error() -> response, Some(MisalignmentErrorDetails { error_type: Some("unsafe_activity".to_string()), + review_target: None, detailed_explanation: None, steer: None, }), @@ -255,6 +259,7 @@ async fn assert_policy_violation_completes_turn_with_typed_terminal_error( .await?; assert!(!rollout.contains(EXPLANATION)); assert!(!rollout.contains(STEER)); + assert!(!rollout.contains(REVIEW_TARGET)); Ok(()) } @@ -262,6 +267,7 @@ async fn assert_policy_violation_completes_turn_with_typed_terminal_error( fn resumable_misalignment() -> MisalignmentErrorDetails { MisalignmentErrorDetails { error_type: Some("unauthorized_data_transfer".to_string()), + review_target: Some(REVIEW_TARGET.to_string()), detailed_explanation: Some(EXPLANATION.to_string()), steer: Some(MisalignmentSteer { message: STEER.to_string(), diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 9c4cd1be08..c5504e2031 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -387,6 +387,7 @@ fn map_api_error_preserves_misalignment_details_from_403_body() { "code": "misalignment_policy_violation", "misalignment": { "error_type": "unauthorized_data_transfer", + "review_target": " RB/opaque== ", "detailed_explanation": "The agent attempted an external transfer.", "steer": { "message": "Do not transfer the user's files." } } @@ -413,6 +414,7 @@ fn map_api_error_preserves_misalignment_details_from_403_body() { misalignment, &Some(MisalignmentErrorDetails { error_type: Some("unauthorized_data_transfer".to_string()), + review_target: Some(" RB/opaque== ".to_string()), detailed_explanation: Some("The agent attempted an external transfer.".to_string()), steer: Some(codex_protocol::protocol::MisalignmentSteer { message: "Do not transfer the user's files.".to_string(), @@ -432,6 +434,7 @@ fn map_api_error_preserves_misalignment_details_from_wrapped_websocket_error() { "code": "misalignment_policy_violation", "misalignment": { "error_type": "future_safety_category", + "review_target": " RB/opaque== ", "detailed_explanation": "The agent attempted an external transfer.", "steer": { "message": "Do not transfer the user's files." } } @@ -461,6 +464,7 @@ fn map_api_error_preserves_misalignment_details_from_wrapped_websocket_error() { misalignment, &Some(MisalignmentErrorDetails { error_type: Some("future_safety_category".to_string()), + review_target: Some(" RB/opaque== ".to_string()), detailed_explanation: Some("The agent attempted an external transfer.".to_string()), steer: Some(codex_protocol::protocol::MisalignmentSteer { message: "Do not transfer the user's files.".to_string(), diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index b9739289d1..ef02dfdf9a 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1251,6 +1251,7 @@ mod tests { misalignment, &Some(MisalignmentErrorDetails { error_type: Some("future_safety_category".to_string()), + review_target: None, detailed_explanation: Some( "The agent attempted an external transfer.".to_string() ), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index ddd96eda5b..db22fdcb5b 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -50,6 +50,10 @@ const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; const WORKSPACE_KIND_KEY: &str = "workspace_kind"; +fn is_targeted_continuation(value: &str) -> bool { + serde_json::from_str::(value).is_ok_and(|value| value.get("review_target").is_some()) +} + /// Captured execution settings shared by Responses and MCP metadata. pub(crate) struct ExecutionMetadata<'a> { pub(crate) model: &'a str, @@ -273,6 +277,14 @@ impl TurnMetadataState { execution: ExecutionMetadata<'_>, ) -> Option { let mut responses_metadata = self.mcp_metadata_template(); + if responses_metadata + .extra + .get("misalignment_override") + .is_some_and(|value| is_targeted_continuation(value)) + { + // A review target is for Responses admission, not external tools or hooks. + responses_metadata.extra.remove("misalignment_override"); + } execution.apply_to(&mut responses_metadata); // Never serialize harness-owned tool inventory for external MCP servers. responses_metadata.tool_namespaces_info = None; @@ -306,11 +318,22 @@ impl TurnMetadataState { window_id: String, request_kind: CodexResponsesRequestKind, ) -> CodexResponsesMetadata { + let mut metadata = self.responses_metadata_template(); + if matches!(request_kind, CodexResponsesRequestKind::Compaction(_)) + && metadata + .extra + .get("misalignment_override") + .is_some_and(|value| is_targeted_continuation(value)) + { + // Target-based intent belongs to the pending turn, not compaction. Preserve the + // legacy timestamp path until servers can admit pre-turn compaction separately. + metadata.extra.remove("misalignment_override"); + } CodexResponsesMetadata { installation_id, window_id, request_kind: Some(request_kind), - ..self.responses_metadata_template() + ..metadata } } @@ -364,22 +387,32 @@ impl TurnMetadataState { &self, responsesapi_client_metadata: HashMap, ) { - *self + let mut metadata = filter_extra_metadata(responsesapi_client_metadata); + let mut current = self .responsesapi_client_metadata .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - filter_extra_metadata(responsesapi_client_metadata); + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(continuation) = current + .get("misalignment_override") + .filter(|value| is_targeted_continuation(value)) + { + // Steering may update other metadata, but cannot replace the reviewed block. + metadata.insert("misalignment_override".to_string(), continuation.clone()); + } + *current = metadata; } pub(crate) fn set_responses_api_metadata( &self, responses_api_metadata: BTreeMap, ) { + let mut metadata = filter_extra_metadata(responses_api_metadata); + // Only explicit per-turn metadata can acknowledge a reviewed block. + metadata.remove("misalignment_override"); *self .responses_api_metadata .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - filter_extra_metadata(responses_api_metadata); + .unwrap_or_else(std::sync::PoisonError::into_inner) = metadata; } pub(crate) fn workspace_kind(&self) -> Option { diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index a4ea4b3597..5ad52a1dda 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -1031,8 +1031,12 @@ fn turn_metadata_state_bounds_trigger_only_for_analytics() { } } -#[test] -fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { +#[test_case::test_case(r#"{"review_target":"RB._~:-opaque"}"#, None; "target")] +#[test_case::test_case(r#"{"timestamp":1750000000}"#, Some(r#"{"timestamp":1750000000}"#); "legacy_timestamp")] +fn turn_metadata_state_overlays_compaction_only_on_compaction_requests( + continuation: &str, + compaction_override: Option<&str>, +) { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); @@ -1051,14 +1055,32 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { /*auto_review_enabled*/ false, &model_info_from_slug("gpt-5.4"), ); - state.set_responses_api_metadata(BTreeMap::from([( - "codex_security_surface".to_string(), - "sdk".to_string(), - )])); - state.set_responsesapi_client_metadata(HashMap::from([( - "compaction".to_string(), - "client-supplied".to_string(), - )])); + state.set_responses_api_metadata(BTreeMap::from([ + ("codex_security_surface".to_string(), "sdk".to_string()), + ( + "misalignment_override".to_string(), + r#"{"review_target":"RC"}"#.to_string(), + ), + ])); + let ordinary_json: Value = + serde_json::from_str(&test_turn_responses_metadata_json(&state, "thread-a:1")) + .expect("json"); + assert!(ordinary_json.get("misalignment_override").is_none()); + state.set_responsesapi_client_metadata(HashMap::from([ + ("compaction".to_string(), "client-supplied".to_string()), + ( + "misalignment_override".to_string(), + continuation.to_string(), + ), + ])); + + let mcp_metadata = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("MCP metadata"); + assert_eq!( + mcp_metadata["misalignment_override"].as_str(), + compaction_override + ); let compact_header = test_compaction_responses_metadata_json( &state, @@ -1075,6 +1097,10 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { assert_eq!(compact_json["turn_id"].as_str(), Some("turn-a")); assert_eq!(compact_json[WINDOW_ID_KEY].as_str(), Some("thread-a:2")); assert_eq!(compact_json["codex_security_surface"].as_str(), Some("sdk")); + assert_eq!( + compact_json["misalignment_override"].as_str(), + compaction_override + ); assert_eq!( compact_json["compaction"], serde_json::json!({ @@ -1088,6 +1114,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { let regular_header = test_turn_responses_metadata_json(&state, "thread-a:3"); let regular_json: Value = serde_json::from_str(®ular_header).expect("json"); + assert_eq!(regular_json["misalignment_override"], continuation); assert_eq!(regular_json["request_kind"].as_str(), Some("turn")); assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3")); assert_eq!(regular_json["codex_security_surface"].as_str(), Some("sdk")); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index bd120333e5..8368404004 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -4765,18 +4765,39 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); }); - let codex = builder.build(&server).await.unwrap().codex; + let codex = builder.build_with_auto_env(&server).await.unwrap().codex; + let continuation = r#"{"review_target":"RB._~:-opaque"}"#; codex - .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { - text: FUNCTION_CALL_LIMIT_MSG.into(), - text_elements: Vec::new(), - }])) + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: FUNCTION_CALL_LIMIT_MSG.into(), + text_elements: Vec::new(), + }]) + .with_responses_metadata(Some(std::collections::HashMap::from([( + "misalignment_override".to_string(), + continuation.to_string(), + )]))), + ) .await .unwrap(); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; + for (request, expected) in [ + (first_turn_mock.single_request(), Some(continuation)), + (auto_compact_mock.single_request(), None), + (post_auto_compact_mock.single_request(), Some(continuation)), + ] { + let metadata: Value = serde_json::from_str( + request.body_json()["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("turn metadata"), + ) + .expect("valid turn metadata"); + assert_eq!(metadata["misalignment_override"].as_str(), expected); + } + // Assert first request captured expected user message that triggers function call. let first_request = first_turn_mock.single_request().input(); assert!( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b3b5f3aaf3..0ee0c4471a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2040,6 +2040,9 @@ pub struct MisalignmentErrorDetails { /// Model-visible instruction to submit if the user elects to continue. #[serde(default, skip_serializing_if = "Option::is_none")] pub steer: Option, + /// Opaque server-issued block target, echoed verbatim only on explicit continuation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_target: Option, } impl fmt::Debug for MisalignmentErrorDetails { @@ -2052,6 +2055,7 @@ impl fmt::Debug for MisalignmentErrorDetails { &self.detailed_explanation.is_some(), ) .field("has_steer", &self.steer.is_some()) + .field("has_review_target", &self.review_target.is_some()) .finish() } } @@ -5869,6 +5873,7 @@ mod tests { misalignment: Some(MisalignmentErrorDetails { error_type: Some("unauthorized_data_transfer".to_string()), detailed_explanation: Some("Sensitive customer explanation".to_string()), + review_target: Some("sensitive-review-target".to_string()), steer: Some(MisalignmentSteer { message: "Sensitive customer steering".to_string(), }), @@ -5890,6 +5895,7 @@ mod tests { let debug = format!("{event:?}"); assert!(!debug.contains("Sensitive customer explanation")); assert!(!debug.contains("Sensitive customer steering")); + assert!(!debug.contains("sensitive-review-target")); } #[test] diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index 926e70748a..c3b8283fc0 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -13,6 +13,7 @@ fn policy_error() -> AppServerTurnError { codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation), additional_details: None, misalignment: Some(MisalignmentErrorDetails { + review_target: Some("RB".to_string()), error_type: None, detailed_explanation: Some( "The proposed action exceeded the request.\n\n".repeat(1_500), diff --git a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs index b01059cc41..a5aa8b9d83 100644 --- a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs @@ -237,6 +237,7 @@ async fn misalignment_turn_end_discards_history_search_and_question_drafts() { fn review_details() -> codex_app_server_protocol::MisalignmentErrorDetails { codex_app_server_protocol::MisalignmentErrorDetails { error_type: Some("new_category".to_string()), + review_target: None, detailed_explanation: Some( "The agent proposed a change outside your instructions.".to_string(), ), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 16dd0dcbf7..cb1ad32ee8 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -8763,6 +8763,13 @@ class MisalignmentErrorDetails(BaseModel): description="Open-ended classification; clients must accept categories added by Responses.", ), ] = None + review_target: Annotated[ + str | None, + Field( + alias="reviewTarget", + description="Opaque server-issued block target. Presence alone does not enable target-based continuation.", + ), + ] = None steer: Annotated[ MisalignmentSteer | None, Field(